fix: normalize unicode escape sequences and reformat multi-line expressions across codebase

This commit is contained in:
2026-06-09 16:48:08 +00:00
parent 66dfda88bc
commit c4f2937415
175 changed files with 12660 additions and 4175 deletions
+49 -14
View File
@@ -11,8 +11,19 @@ logger = logging.getLogger(__name__)
class ConfigField:
def __init__(self, key, label, type="str", default="", help="",
options=None, secret=False, minimum=None, maximum=None, group="General"):
def __init__(
self,
key,
label,
type="str",
default="",
help="",
options=None,
secret=False,
minimum=None,
maximum=None,
group="General",
):
self.key = key
self.label = label
self.type = type
@@ -131,22 +142,40 @@ class BaseService(ABC):
self._last_command = None
self._last_persist = None
self.enabled_field = ConfigField(
self.enabled_key, "Enabled", type="bool", default=self.default_enabled,
self.enabled_key,
"Enabled",
type="bool",
default=self.default_enabled,
help="When enabled the service runs on its interval and starts on boot.",
group="General")
group="General",
)
self.interval_field = ConfigField(
self.interval_key, "Run interval (seconds)", type="int",
default=interval_seconds, minimum=self.min_interval,
self.interval_key,
"Run interval (seconds)",
type="int",
default=interval_seconds,
minimum=self.min_interval,
help=f"Delay between runs. Minimum {self.min_interval} seconds.",
group="General")
group="General",
)
self.log_size_field = ConfigField(
self.log_size_key, "Log buffer size", type="int",
default=self.DEFAULT_LOG_SIZE, minimum=1, maximum=200,
self.log_size_key,
"Log buffer size",
type="int",
default=self.DEFAULT_LOG_SIZE,
minimum=1,
maximum=200,
help="Number of recent log lines to keep.",
group="Advanced")
group="Advanced",
)
def all_fields(self) -> list:
return [self.enabled_field, self.interval_field, *self.config_fields, self.log_size_field]
return [
self.enabled_field,
self.interval_field,
*self.config_fields,
self.log_size_field,
]
def _grouped_fields(self) -> list:
groups: dict = {}
@@ -165,10 +194,14 @@ class BaseService(ABC):
return "running" if self._running else "stopped"
def is_enabled(self) -> bool:
return get_setting(self.enabled_key, "1" if self.default_enabled else "0") == "1"
return (
get_setting(self.enabled_key, "1" if self.default_enabled else "0") == "1"
)
def current_interval(self) -> int:
return max(self.min_interval, get_int_setting(self.interval_key, self.interval_seconds))
return max(
self.min_interval, get_int_setting(self.interval_key, self.interval_seconds)
)
def get_config(self) -> dict:
return {field.key: field.read() for field in self.all_fields()}
@@ -269,7 +302,9 @@ class BaseService(ABC):
except Exception as e:
self.log(f"Error in run_once: {e}")
self.interval_seconds = self.current_interval()
self._next_due = datetime.now(timezone.utc) + timedelta(seconds=self.interval_seconds)
self._next_due = datetime.now(timezone.utc) + timedelta(
seconds=self.interval_seconds
)
self._next_run = self._next_due.isoformat()
self.log(f"Next run in {self.interval_seconds}s")
self._persist_state(force=True)
+321 -102
View File
@@ -11,8 +11,15 @@ from faker import Faker
from devplacepy.services.bot.browser import BotBrowser
from devplacepy.services.bot.config import (
CATEGORIES, FEED_TOPICS, GIST_LANGUAGES, PERSONAS, PROJECT_STATUSES,
PROJECT_TYPES, REACT_RATE_DEFAULT, REACT_RATES, SEARCH_TERMS,
CATEGORIES,
FEED_TOPICS,
GIST_LANGUAGES,
PERSONAS,
PROJECT_STATUSES,
PROJECT_TYPES,
REACT_RATE_DEFAULT,
REACT_RATES,
SEARCH_TERMS,
)
from devplacepy.services.bot.llm import LLMClient
from devplacepy.services.bot.news_fetcher import NewsFetcher
@@ -33,8 +40,11 @@ class DevPlaceBot:
if not self.state.persona:
self.state.persona = random.choice(PERSONAS)
self.llm = LLMClient(
cfg.api_key, cfg.api_url, cfg.model,
cfg.input_cost_per_1m, cfg.output_cost_per_1m,
cfg.api_key,
cfg.api_url,
cfg.model,
cfg.input_cost_per_1m,
cfg.output_cost_per_1m,
)
self.llm.total_cost = self.state.total_cost
self.llm.total_calls = self.state.total_calls
@@ -96,7 +106,9 @@ class DevPlaceBot:
try:
return await asyncio.to_thread(fn, *args)
except Exception as e:
logger.warning("LLM generation failed (%s): %s", getattr(fn, "__name__", fn), e)
logger.warning(
"LLM generation failed (%s): %s", getattr(fn, "__name__", fn), e
)
return None
def _cost_summary(self, label: str) -> None:
@@ -116,7 +128,9 @@ class DevPlaceBot:
)
def _session_banner(self, mood: str, session_len: int) -> None:
self._notify(f"[{self._identity()}] session start mood={mood} target~{session_len} actions")
self._notify(
f"[{self._identity()}] session start mood={mood} target~{session_len} actions"
)
async def _refill_cache_async(self, kind: str) -> None:
try:
@@ -125,9 +139,15 @@ class DevPlaceBot:
if articles:
a = random.choice(articles)
desc = a.get("description", "")[:500]
cat = await asyncio.to_thread(self.llm.select_category, a["title"], desc[:600])
cat = await asyncio.to_thread(
self.llm.select_category, a["title"], desc[:600]
)
content = await asyncio.to_thread(
self.llm.generate_post, a["title"], desc, self.state.persona, cat,
self.llm.generate_post,
a["title"],
desc,
self.state.persona,
cat,
)
self._post_cache.append((a["title"], desc, content, cat))
elif kind == "project":
@@ -165,7 +185,9 @@ class DevPlaceBot:
]
handle = random.choice(styles)
if random.random() < 0.15:
suffix = random.choice([str(random.randint(1, 99)), str(random.randint(1970, 2005))])
suffix = random.choice(
[str(random.randint(1, 99)), str(random.randint(1970, 2005))]
)
sep = random.choice(["", "_", "."])
handle = f"{handle}{sep}{suffix}"
handle = re.sub(r"[^a-z0-9._-]", "", handle)[:20]
@@ -219,7 +241,9 @@ class DevPlaceBot:
self._log(f"Registered as {self.state.username}")
return True
if attempt >= 2:
self.state.username = f"{self._generate_handle()}{random.randint(10, 999)}"
self.state.username = (
f"{self._generate_handle()}{random.randint(10, 999)}"
)
else:
self.state.username = self._generate_handle()
self.state.email = self.faker.email()
@@ -233,7 +257,9 @@ class DevPlaceBot:
sec_per_word = 60.0 / wpm
curl = await self.b.url()
if "/posts/" in curl:
body = await self.b.text("article") or await self.b.text(".post-content") or ""
body = (
await self.b.text("article") or await self.b.text(".post-content") or ""
)
wc = len(body.split())
jitter = random.uniform(0.7, 1.25)
pause = min(max(wc * sec_per_word * jitter, min_s), max_s)
@@ -365,7 +391,9 @@ class DevPlaceBot:
self.state.voted_post_ids.append(post_id)
if len(self.state.voted_post_ids) > 1000:
self.state.voted_post_ids = self.state.voted_post_ids[-500:]
self._action("VOTE", f"post {post_id[:12] or '?'} (total {self.state.votes_cast})")
self._action(
"VOTE", f"post {post_id[:12] or '?'} (total {self.state.votes_cast})"
)
await b._idle(1.0, 2.0)
return True
except Exception as e:
@@ -473,19 +501,31 @@ class DevPlaceBot:
self._log(f"Own {kind} - not commenting")
return False
if thread_id and thread_id in self.state.commented_post_ids and not mentioned_here:
if (
thread_id
and thread_id in self.state.commented_post_ids
and not mentioned_here
):
self._log(f"Already commented on {thread_id[:12]}, not repeating")
return False
post_body = (await b.text(".post-content") or await b.text(".gist-detail-desc")
or await b.text(".news-detail-content") or await b.text(".news-detail-desc")
or await b.text(".project-detail-desc") or await b.text(".rendered-content")
or await b.text("article") or "")
post_body = (
await b.text(".post-content")
or await b.text(".gist-detail-desc")
or await b.text(".news-detail-content")
or await b.text(".news-detail-desc")
or await b.text(".project-detail-desc")
or await b.text(".rendered-content")
or await b.text("article")
or ""
)
if not post_body:
self._log("Comment: no content found")
return False
known = list({u for u in self.state.known_users if u and u != self.state.username})
known = list(
{u for u in self.state.known_users if u and u != self.state.username}
)
mention_target = ""
if known and random.random() < 0.3:
mention_target = random.choice(known)
@@ -493,11 +533,17 @@ class DevPlaceBot:
comment = ""
for attempt in range(2):
candidate = await self._generate(
self.llm.generate_comment, post_body[:1500], self.state.persona, mention_target, "",
self.llm.generate_comment,
post_body[:1500],
self.state.persona,
mention_target,
"",
)
if not candidate:
continue
verdict = await self._generate(self.llm.quality_check, "comment", candidate, post_body[:1500])
verdict = await self._generate(
self.llm.quality_check, "comment", candidate, post_body[:1500]
)
if verdict is None or verdict[0]:
comment = candidate
break
@@ -562,7 +608,10 @@ class DevPlaceBot:
if kind == "news":
self.state.news_comments += 1
tag = "NEWS" if kind == "news" else "COMMENT"
self._action(tag, f"on {kind}{mention_log} [{self.state.comments_posted}]: {comment[:60]}")
self._action(
tag,
f"on {kind}{mention_log} [{self.state.comments_posted}]: {comment[:60]}",
)
await asyncio.sleep(2)
return True
self._log("Comment: submit button not found/enabled")
@@ -573,7 +622,9 @@ class DevPlaceBot:
curl = await b.url()
if "/posts/" not in curl:
return False
comments = b.page.locator(".comment, li.comment, article.comment, .comment-item")
comments = b.page.locator(
".comment, li.comment, article.comment, .comment-item"
)
count = await comments.count()
if count == 0:
return False
@@ -595,7 +646,9 @@ class DevPlaceBot:
if await author_link.count() > 0:
ah = await author_link.get_attribute("href") or ""
if "/profile/" in ah:
mentioner = ah.split("/profile/")[-1].split("?")[0].split("/")[0]
mentioner = (
ah.split("/profile/")[-1].split("?")[0].split("/")[0]
)
except Exception:
pass
if not mentioner or mentioner.lower() == self.state.username.lower():
@@ -615,7 +668,11 @@ class DevPlaceBot:
continue
post_body = await b.text(".post-content") or await b.text("article") or ""
reply = await self._generate(
self.llm.generate_comment, post_body[:1200], self.state.persona, mentioner, text,
self.llm.generate_comment,
post_body[:1200],
self.state.persona,
mentioner,
text,
)
if not reply:
self._log("Reply: generation failed/empty")
@@ -647,7 +704,10 @@ class DevPlaceBot:
self.state.comments_posted += 1
if mentioner not in self.state.known_users:
self.state.known_users.append(mentioner)
self._action("REPLY", f"to @{mentioner} [{self.state.comments_posted}]: {reply[:60]}")
self._action(
"REPLY",
f"to @{mentioner} [{self.state.comments_posted}]: {reply[:60]}",
)
await asyncio.sleep(2)
return True
return False
@@ -655,8 +715,10 @@ class DevPlaceBot:
async def _vote_on_gist(self) -> bool:
b = self.b
sel = ("form[action*='/votes/gist/'] button.gist-card-star, "
"form[action*='/votes/gist/'] button[type='submit']")
sel = (
"form[action*='/votes/gist/'] button.gist-card-star, "
"form[action*='/votes/gist/'] button[type='submit']"
)
btns = b.page.locator(sel)
count = await btns.count()
if count == 0:
@@ -687,7 +749,9 @@ class DevPlaceBot:
self.state.voted_post_ids.append(gist_id)
if len(self.state.voted_post_ids) > 1000:
self.state.voted_post_ids = self.state.voted_post_ids[-500:]
self._action("VOTE", f"gist {gist_id[:12] or '?'} (total {self.state.votes_cast})")
self._action(
"VOTE", f"gist {gist_id[:12] or '?'} (total {self.state.votes_cast})"
)
await b._idle(1.0, 2.0)
return True
except Exception as e:
@@ -696,8 +760,10 @@ class DevPlaceBot:
async def _vote_on_project(self) -> bool:
b = self.b
sel = ("form[action*='/votes/project/'] button.project-star-btn, "
"form[action*='/votes/project/'] button[type='submit']")
sel = (
"form[action*='/votes/project/'] button.project-star-btn, "
"form[action*='/votes/project/'] button[type='submit']"
)
btns = b.page.locator(sel)
count = await btns.count()
if count == 0:
@@ -729,7 +795,10 @@ class DevPlaceBot:
self.state.voted_post_ids.append(project_id)
if len(self.state.voted_post_ids) > 1000:
self.state.voted_post_ids = self.state.voted_post_ids[-500:]
self._action("STAR", f"project {project_id[:12] or '?'} (total {self.state.votes_cast})")
self._action(
"STAR",
f"project {project_id[:12] or '?'} (total {self.state.votes_cast})",
)
await b._idle(1.0, 2.0)
return True
except Exception as e:
@@ -765,14 +834,19 @@ class DevPlaceBot:
logger.debug("_vote_on_poll click failed: %s", e)
continue
try:
await poll.locator(".poll-option.chosen").first.wait_for(state="visible", timeout=4000)
await poll.locator(".poll-option.chosen").first.wait_for(
state="visible", timeout=4000
)
except Exception as e:
logger.debug("_vote_on_poll confirm failed: %s", e)
self.state.polls_voted.append(poll_uid)
if len(self.state.polls_voted) > 1000:
self.state.polls_voted = self.state.polls_voted[-500:]
self.state.poll_votes_cast += 1
self._action("POLL", f"voted {poll_uid[:12]} opt {option_uid[:8] or '?'} (total {self.state.poll_votes_cast})")
self._action(
"POLL",
f"voted {poll_uid[:12]} opt {option_uid[:8] or '?'} (total {self.state.poll_votes_cast})",
)
await b._idle(1.0, 2.0)
return True
return False
@@ -789,10 +863,16 @@ class DevPlaceBot:
return text[:1200]
except Exception as e:
logger.debug("_reaction_content container failed: %s", e)
body = (await self.b.text(".post-content") or await self.b.text(".gist-detail-desc")
or await self.b.text(".news-detail-content") or await self.b.text(".news-detail-desc")
or await self.b.text(".project-detail-desc") or await self.b.text(".rendered-content")
or await self.b.text("article") or "")
body = (
await self.b.text(".post-content")
or await self.b.text(".gist-detail-desc")
or await self.b.text(".news-detail-content")
or await self.b.text(".news-detail-desc")
or await self.b.text(".project-detail-desc")
or await self.b.text(".rendered-content")
or await self.b.text("article")
or ""
)
return body[:1200]
async def _react_to_object(self) -> bool:
@@ -822,7 +902,9 @@ class DevPlaceBot:
if not snippet:
self._log(f"Reaction: no content for {rtype} {uid[:12]}")
return False
emoji = await self._generate(self.llm.select_reaction, snippet, self.state.persona)
emoji = await self._generate(
self.llm.select_reaction, snippet, self.state.persona
)
if not emoji:
self._log(f"Reaction: nothing fitting for {rtype} {uid[:12]}")
return False
@@ -845,11 +927,16 @@ class DevPlaceBot:
logger.debug("_react_to_object click failed: %s", e)
return False
try:
await bar.locator(".reaction-chip.reacted").first.wait_for(state="visible", timeout=4000)
await bar.locator(".reaction-chip.reacted").first.wait_for(
state="visible", timeout=4000
)
except Exception as e:
logger.debug("_react_to_object confirm failed: %s", e)
self.state.reactions_cast += 1
self._action("REACT", f"{emoji} on {rtype} {uid[:12]} (total {self.state.reactions_cast})")
self._action(
"REACT",
f"{emoji} on {rtype} {uid[:12]} (total {self.state.reactions_cast})",
)
await b._idle(1.0, 2.0)
return True
return False
@@ -908,7 +995,9 @@ class DevPlaceBot:
return False
await b._idle(0.5, 1.2)
ok = await b.click("#create-gist-modal button[type='submit'], #create-gist-modal .btn-primary")
ok = await b.click(
"#create-gist-modal button[type='submit'], #create-gist-modal .btn-primary"
)
if not ok:
self._log("Create gist: submit not found")
return False
@@ -918,7 +1007,10 @@ class DevPlaceBot:
if url not in self.state.own_post_urls:
self.state.own_post_urls.append(url)
self.state.gists_created += 1
self._action("GIST", f"{title[:40]} [{language}] [{self.state.gists_created}] -> {url[-50:]}")
self._action(
"GIST",
f"{title[:40]} [{language}] [{self.state.gists_created}] -> {url[-50:]}",
)
return True
self._log("Create gist: no redirect to gist detail")
return False
@@ -953,14 +1045,22 @@ class DevPlaceBot:
asyncio.create_task(self._refill_cache_async("post"))
else:
articles = self.news.fetch()
article = self.registry.reserve_unused(articles, self.state.username, self.llm.clean)
article = self.registry.reserve_unused(
articles, self.state.username, self.llm.clean
)
if not article:
self._log("Create post: no unused articles available")
return False
desc = article.get("description", "")[:500]
topic = await self._generate(self.llm.select_category, article["title"], desc[:600])
topic = await self._generate(
self.llm.select_category, article["title"], desc[:600]
)
content = await self._generate(
self.llm.generate_post, article["title"], desc, self.state.persona, topic or "random",
self.llm.generate_post,
article["title"],
desc,
self.state.persona,
topic or "random",
)
if not content:
self._log("Create post: generation failed/empty")
@@ -969,17 +1069,25 @@ class DevPlaceBot:
article_title = article["title"]
article_desc = desc
verdict = await self._generate(self.llm.quality_check, "post", content, article_desc)
verdict = await self._generate(
self.llm.quality_check, "post", content, article_desc
)
if verdict is not None and not verdict[0]:
self.state.quality_rejections += 1
self._log(f"Post quality reject ({verdict[1]}), regenerating")
content = await self._generate(
self.llm.generate_post, article_title, article_desc, self.state.persona, topic,
self.llm.generate_post,
article_title,
article_desc,
self.state.persona,
topic,
)
if not content:
self._log("Create post: regeneration failed/empty")
return False
verdict = await self._generate(self.llm.quality_check, "post", content, article_desc)
verdict = await self._generate(
self.llm.quality_check, "post", content, article_desc
)
if verdict is not None and not verdict[0]:
self.state.quality_rejections += 1
self._log(f"Post quality reject ({verdict[1]}), skipping")
@@ -993,7 +1101,9 @@ class DevPlaceBot:
clean_title = self.llm.clean(article_title[:200]) if article_title else ""
if clean_title:
if not self.registry.reserve(clean_title, self.state.username):
self._log(f"Create post: article '{clean_title[:50]}...' already posted by another bot, skipping")
self._log(
f"Create post: article '{clean_title[:50]}...' already posted by another bot, skipping"
)
return False
ts = f"input[name='topic'][value='{topic}']"
@@ -1039,7 +1149,10 @@ class DevPlaceBot:
self.state.own_post_urls.append(post_url)
if "/posts/" in post_url and post_url not in self.state.known_posts:
self.state.known_posts.append(post_url)
self._action("POST", f"{topic} [{self.state.created_posts}]: {clean_title[:50] or content[:50]} -> {post_url[-45:]}")
self._action(
"POST",
f"{topic} [{self.state.created_posts}]: {clean_title[:50] or content[:50]} -> {post_url[-45:]}",
)
return True
async def _click_post_link(self) -> bool:
@@ -1137,7 +1250,9 @@ class DevPlaceBot:
if uname not in self.state.known_users:
self.state.known_users.append(uname)
self.state.profiles_viewed += 1
self._action("PROFILE", f"viewed @{uname} [{self.state.profiles_viewed}]")
self._action(
"PROFILE", f"viewed @{uname} [{self.state.profiles_viewed}]"
)
return True
except Exception as e:
logger.debug("_click_profile_link failed: %s", e)
@@ -1189,7 +1304,9 @@ class DevPlaceBot:
ok = await b.click("#create-project-modal .btn-primary")
if ok:
self.state.projects_created += 1
self._action("PROJECT", f"{title} ({ptype}) [{self.state.projects_created}]")
self._action(
"PROJECT", f"{title} ({ptype}) [{self.state.projects_created}]"
)
await asyncio.sleep(2)
return True
return False
@@ -1205,7 +1322,15 @@ class DevPlaceBot:
if not await b.text("#create-bug-modal"):
return False
topic = random.choice(["UI glitch", "Broken link", "Performance issue", "Auth problem", "Formatting error"])
topic = random.choice(
[
"UI glitch",
"Broken link",
"Performance issue",
"Auth problem",
"Formatting error",
]
)
if self._bug_cache:
title, desc = self._bug_cache.pop(0)
asyncio.create_task(self._refill_cache_async("bug"))
@@ -1221,7 +1346,9 @@ class DevPlaceBot:
await b.fill("textarea[name='description']", desc[:2000])
await b._idle(0.5, 1.0)
ok = await b.click("button:has-text('Submit Report'), #create-bug-modal .btn-primary")
ok = await b.click(
"button:has-text('Submit Report'), #create-bug-modal .btn-primary"
)
if ok:
self.state.bugs_filed += 1
self._action("BUG", f"{title[:50]} [{self.state.bugs_filed}]")
@@ -1247,19 +1374,29 @@ class DevPlaceBot:
await b._idle(0.3, 0.8)
filled = ["bio"]
fields = await self._generate(self.llm.generate_profile_fields, self.state.username)
fields = await self._generate(
self.llm.generate_profile_fields, self.state.username
)
if fields:
location, git_link, website = fields
for field_name, value in (("location", location), ("git_link", git_link), ("website", website)):
for field_name, value in (
("location", location),
("git_link", git_link),
("website", website),
):
if not value:
continue
await b.click(f"[data-edit-field='{field_name}']")
await b._idle(0.2, 0.5)
if await b.fill(f"input[name='{field_name}'], textarea[name='{field_name}']", value):
if await b.fill(
f"input[name='{field_name}'], textarea[name='{field_name}']", value
):
filled.append(field_name)
await b._idle(0.2, 0.5)
ok = await b.click("form[action*='/profile/update'] button[type='submit'], button[type='submit']")
ok = await b.click(
"form[action*='/profile/update'] button[type='submit'], button[type='submit']"
)
if ok:
self._action("PROFILE", f"updated own profile ({', '.join(filled)})")
await asyncio.sleep(2)
@@ -1282,7 +1419,10 @@ class DevPlaceBot:
return False
try:
await form.first.click(timeout=3000)
self._action("FOLLOW", f"{'followed' if text.lower() == 'follow' else 'unfollowed'} @{uname}")
self._action(
"FOLLOW",
f"{'followed' if text.lower() == 'follow' else 'unfollowed'} @{uname}",
)
await b._idle(1.0, 2.0)
return True
except Exception as e:
@@ -1325,7 +1465,9 @@ class DevPlaceBot:
href = href_attr
else:
try:
inner = el.locator("a[href*='/posts/'], a[href*='/profile/']").first
inner = el.locator(
"a[href*='/posts/'], a[href*='/profile/']"
).first
if await inner.count() > 0:
href = (await inner.get_attribute("href")) or ""
except Exception:
@@ -1345,7 +1487,9 @@ class DevPlaceBot:
or "replied to you" in tl
or "tagged you" in tl
)
items.append({"key": key, "href": href, "text": text, "is_mention": is_mention})
items.append(
{"key": key, "href": href, "text": text, "is_mention": is_mention}
)
if items:
break
return items
@@ -1358,9 +1502,14 @@ class DevPlaceBot:
if not notifications:
self._log("Notifications: none")
return True
unseen_mentions = [n for n in notifications
if n["is_mention"] and n["key"] not in self.state.notifications_seen]
self._log(f"Notifications: {len(notifications)} ({len(unseen_mentions)} unseen mentions)")
unseen_mentions = [
n
for n in notifications
if n["is_mention"] and n["key"] not in self.state.notifications_seen
]
self._log(
f"Notifications: {len(notifications)} ({len(unseen_mentions)} unseen mentions)"
)
self.state.mentions_received += len(unseen_mentions)
replied = 0
for n in unseen_mentions[:3]:
@@ -1373,12 +1522,18 @@ class DevPlaceBot:
self.state.notifications_seen = self.state.notifications_seen[-250:]
await b._idle(2.0, 5.0)
if replied == 0:
remaining = [n for n in notifications if n["key"] not in self.state.notifications_seen]
remaining = [
n
for n in notifications
if n["key"] not in self.state.notifications_seen
]
if remaining:
pick = random.choice(remaining)
target = pick["href"]
if target and not target.startswith("http"):
target = self.base_url + (target if target.startswith("/") else "/" + target)
target = self.base_url + (
target if target.startswith("/") else "/" + target
)
if target:
try:
await b.goto(target)
@@ -1387,7 +1542,9 @@ class DevPlaceBot:
logger.debug("notification goto failed: %s", e)
self.state.notifications_seen.append(pick["key"])
try:
mark = b.page.locator("button:has-text('Mark all read'), form[action*='/mark-all-read'] button")
mark = b.page.locator(
"button:has-text('Mark all read'), form[action*='/mark-all-read'] button"
)
if await mark.count() > 0:
await mark.first.click(timeout=3000)
await b._idle(0.5, 1.5)
@@ -1436,7 +1593,9 @@ class DevPlaceBot:
if await author_link.count() > 0:
ah = await author_link.get_attribute("href") or ""
if "/profile/" in ah:
mentioner = ah.split("/profile/")[-1].split("?")[0].split("/")[0]
mentioner = (
ah.split("/profile/")[-1].split("?")[0].split("/")[0]
)
except Exception:
mentioner = ""
try:
@@ -1445,18 +1604,26 @@ class DevPlaceBot:
except Exception as e:
logger.debug("scroll mention failed: %s", e)
try:
reply_btn = comment_loc.locator(".comment-action-btn, button:has-text('Reply'), a:has-text('Reply')").first
reply_btn = comment_loc.locator(
".comment-action-btn, button:has-text('Reply'), a:has-text('Reply')"
).first
if await reply_btn.count() > 0:
await reply_btn.click(timeout=3000)
await b._idle(0.5, 1.5)
except Exception as e:
logger.debug("reply-btn click failed: %s", e)
try:
post_body = await b.text(".post-content") or await b.text("article") or snippet
post_body = (
await b.text(".post-content") or await b.text("article") or snippet
)
except Exception:
post_body = snippet
reply = await self._generate(
self.llm.generate_comment, post_body[:1500], self.state.persona, mentioner, parent_text,
self.llm.generate_comment,
post_body[:1500],
self.state.persona,
mentioner,
parent_text,
)
if not reply:
self._log("Mention reply: generation failed/empty")
@@ -1503,7 +1670,9 @@ class DevPlaceBot:
b = self.b
await b.goto(f"{self.base_url}/messages")
await b._idle()
conv = b.page.locator(".messages-conversations a, .conversation-item, .message-item, .messages-list a")
conv = b.page.locator(
".messages-conversations a, .conversation-item, .message-item, .messages-list a"
)
if await conv.count() == 0:
self._log("Messages: no conversations")
return True
@@ -1520,15 +1689,19 @@ class DevPlaceBot:
async def _compose_message(self, reply: bool = False) -> bool:
b = self.b
box = b.page.locator("form[action*='/messages/send'] input[name='content'], "
"input[name='content'][placeholder*='message'], "
"form[action*='/messages/send'] textarea[name='content']")
box = b.page.locator(
"form[action*='/messages/send'] input[name='content'], "
"input[name='content'][placeholder*='message'], "
"form[action*='/messages/send'] textarea[name='content']"
)
if await box.count() == 0:
self._log("Message: compose box not found")
return False
context = ""
try:
context = (await b.text(".messages-thread") or await b.text(".message-list") or "")[:400]
context = (
await b.text(".messages-thread") or await b.text(".message-list") or ""
)[:400]
except Exception:
context = ""
text = await self._generate(self.llm.generate_dm, self.state.persona, context)
@@ -1537,12 +1710,19 @@ class DevPlaceBot:
text = text[:500]
if not await self._type_like_human(
"form[action*='/messages/send'] input[name='content'], input[name='content'], "
"form[action*='/messages/send'] textarea[name='content']", text):
"form[action*='/messages/send'] textarea[name='content']",
text,
):
return False
await b._idle(0.4, 1.0)
if await b.click("button.messages-send-btn, form[action*='/messages/send'] button[type='submit']"):
if await b.click(
"button.messages-send-btn, form[action*='/messages/send'] button[type='submit']"
):
self.state.messages_sent += 1
self._action("MESSAGE", f"{'replied' if reply else 'sent'} [{self.state.messages_sent}]: {text[:50]}")
self._action(
"MESSAGE",
f"{'replied' if reply else 'sent'} [{self.state.messages_sent}]: {text[:50]}",
)
await asyncio.sleep(2)
return True
return False
@@ -1575,7 +1755,9 @@ class DevPlaceBot:
nav_links = b.page.locator(".topnav-links a, .topnav a, a.topnav-link")
count = await nav_links.count()
if count == 0:
nav_links = b.page.locator("a[href='/feed'], a[href='/projects'], a[href='/messages'], a[href='/notifications']")
nav_links = b.page.locator(
"a[href='/feed'], a[href='/projects'], a[href='/messages'], a[href='/notifications']"
)
count = await nav_links.count()
if count == 0:
return False
@@ -1592,19 +1774,23 @@ class DevPlaceBot:
return False
async def _browse_section(self) -> bool:
section, query = random.choice([
("feed", f"?tab={random.choice(['trending', 'recent', 'following'])}"),
("feed", f"?topic={random.choice(FEED_TOPICS)}"),
("projects", f"?type={random.choice(PROJECT_TYPES)}"),
("gists", f"?language={random.choice(GIST_LANGUAGES)}"),
])
section, query = random.choice(
[
("feed", f"?tab={random.choice(['trending', 'recent', 'following'])}"),
("feed", f"?topic={random.choice(FEED_TOPICS)}"),
("projects", f"?type={random.choice(PROJECT_TYPES)}"),
("gists", f"?language={random.choice(GIST_LANGUAGES)}"),
]
)
await self.b.goto(f"{self.base_url}/{section}{query}")
self._action("BROWSE", f"/{section}{query}")
await self.b._idle(0.8, 1.8)
return True
async def _search(self, section: str = "feed") -> bool:
terms = list(SEARCH_TERMS.get(self.state.persona, ["python", "rust", "ai", "web"]))
terms = list(
SEARCH_TERMS.get(self.state.persona, ["python", "rust", "ai", "web"])
)
for href in self.state.known_posts[-10:]:
slug = href.rsplit("/", 1)[-1]
words = [w for w in re.split(r"[-_]", slug) if len(w) > 4]
@@ -1617,10 +1803,18 @@ class DevPlaceBot:
await self.b._idle(0.8, 1.8)
return True
async def _cycle(self, mood: str = "normal", *, session_posted: bool = False,
session_projects: int = 0, session_follows: int = 0,
session_gists: int = 0, can_project: bool = False,
can_post: bool = False, can_message: bool = False) -> tuple[int, bool, int, int, int]:
async def _cycle(
self,
mood: str = "normal",
*,
session_posted: bool = False,
session_projects: int = 0,
session_follows: int = 0,
session_gists: int = 0,
can_project: bool = False,
can_post: bool = False,
can_message: bool = False,
) -> tuple[int, bool, int, int, int]:
b = self.b
curl = await b.url()
page_text = (await b.html()).lower()
@@ -1668,7 +1862,12 @@ class DevPlaceBot:
await self._random_nav_click()
await b._idle(1.0, 2.5)
else:
comment_p = {"lurking": 0.5, "casual": 0.75, "normal": 0.85, "deep": 0.9}[mood]
comment_p = {
"lurking": 0.5,
"casual": 0.75,
"normal": 0.85,
"deep": 0.9,
}[mood]
if random.random() < comment_p:
if await self._comment_on_post():
actions += 1
@@ -1722,7 +1921,9 @@ class DevPlaceBot:
elif on_news_detail:
await self._read_like_human(4, 60)
comment_p = {"lurking": 0.4, "casual": 0.6, "normal": 0.7, "deep": 0.8}[mood]
comment_p = {"lurking": 0.4, "casual": 0.6, "normal": 0.7, "deep": 0.8}[
mood
]
if random.random() < comment_p:
if await self._comment_on_post():
actions += 1
@@ -1837,7 +2038,9 @@ class DevPlaceBot:
clicked = True
if not clicked and random.random() < 0.12:
if random.random() < 0.5:
await self._search("feed" if random.random() < 0.7 else "projects")
await self._search(
"feed" if random.random() < 0.7 else "projects"
)
else:
await self._browse_section()
actions += 1
@@ -1940,9 +2143,13 @@ class DevPlaceBot:
sessions_since_project += 1
sessions_since_post += 1
sessions_since_message += 1
can_create_project = sessions_since_project >= 3 and random.random() < 0.3
can_create_project = (
sessions_since_project >= 3 and random.random() < 0.3
)
can_create_post = sessions_since_post >= 2 and random.random() < 0.5
can_create_message = sessions_since_message >= 2 and random.random() < 0.4
can_create_message = (
sessions_since_message >= 2 and random.random() < 0.4
)
while session_actions < max_actions:
if action_limit and lifetime_actions >= action_limit:
@@ -1950,11 +2157,21 @@ class DevPlaceBot:
self._save()
self._cost_summary("final")
return
taken, session_posted, session_projects, session_follows, session_gists = await self._cycle(
mood=mood, session_posted=session_posted,
session_projects=session_projects, session_follows=session_follows,
session_gists=session_gists, can_project=can_create_project,
can_post=can_create_post, can_message=can_create_message,
(
taken,
session_posted,
session_projects,
session_follows,
session_gists,
) = await self._cycle(
mood=mood,
session_posted=session_posted,
session_projects=session_projects,
session_follows=session_follows,
session_gists=session_gists,
can_project=can_create_project,
can_post=can_create_post,
can_message=can_create_message,
)
if session_posted:
sessions_since_post = 0
@@ -1973,7 +2190,9 @@ class DevPlaceBot:
break
intra_pause = random.randint(5, 30)
self._log(f"Session pause {intra_pause}s [{session_actions}/{max_actions}] {self._cost_tag()}")
self._log(
f"Session pause {intra_pause}s [{session_actions}/{max_actions}] {self._cost_tag()}"
)
await asyncio.sleep(intra_pause)
session_ok = session_actions > 0
+4 -2
View File
@@ -21,8 +21,10 @@ class BotBrowser:
self._browser = await self._playwright.chromium.launch(
headless=self._headless,
args=[
"--disable-blink-features=AutomationControlled", "--no-first-run",
"--disable-dev-shm-usage", "--window-size=1280,900",
"--disable-blink-features=AutomationControlled",
"--no-first-run",
"--disable-dev-shm-usage",
"--window-size=1280,900",
],
)
self._context = await self._browser.new_context(
+39 -7
View File
@@ -43,8 +43,21 @@ REACT_RATE_DEFAULT = 0.20
CATEGORIES = ["devlog", "showcase", "question", "rant", "fun", "random"]
GIST_LANGUAGES = [
"python", "javascript", "typescript", "bash", "go", "rust",
"sql", "html", "css", "java", "c", "cpp", "ruby", "php", "lua",
"python",
"javascript",
"typescript",
"bash",
"go",
"rust",
"sql",
"html",
"css",
"java",
"c",
"cpp",
"ruby",
"php",
"lua",
]
FEED_TOPICS = ["devlog", "showcase", "question", "rant", "fun"]
@@ -53,7 +66,13 @@ PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
SEARCH_TERMS = {
"enthusiastic_junior": ["vibe coding", "first app", "react", "python", "ai tools"],
"grumpy_senior": ["rust", "perl", "legacy", "tech debt", "kubernetes"],
"hobbyist_maker": ["arduino", "raspberry pi", "side project", "3d printing", "game"],
"hobbyist_maker": [
"arduino",
"raspberry pi",
"side project",
"3d printing",
"game",
],
"academic_type": ["algorithms", "type theory", "distributed systems", "compilers"],
"minimalist": ["cli", "vim", "golang", "suckless"],
"storyteller": ["postmortem", "war story", "migration", "outage"],
@@ -66,8 +85,21 @@ MIN_POST_LEN = 120
RESTATEMENT_OVERLAP_THRESHOLD = 0.6
GENERIC_COMMENT_PHRASES = [
"great point", "good point", "i agree", "totally agree", "well said",
"nice post", "great post", "thanks for sharing", "interesting read",
"this is interesting", "so true", "couldn't agree more", "spot on",
"great article", "love this", "this is great", "good read",
"great point",
"good point",
"i agree",
"totally agree",
"well said",
"nice post",
"great post",
"thanks for sharing",
"interesting read",
"this is interesting",
"so true",
"couldn't agree more",
"spot on",
"great article",
"love this",
"this is great",
"good read",
]
+115 -45
View File
@@ -12,8 +12,14 @@ logger = logging.getLogger(__name__)
class LLMClient:
def __init__(self, api_key: str, api_url: str, model: str,
input_cost_per_1m: float, output_cost_per_1m: float):
def __init__(
self,
api_key: str,
api_url: str,
model: str,
input_cost_per_1m: float,
output_cost_per_1m: float,
):
if not api_key:
raise RuntimeError("LLM API key not set")
self.api_key = api_key
@@ -31,15 +37,26 @@ class LLMClient:
try:
payload = {
"model": self.model,
"messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}],
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": prompt},
],
"temperature": temperature,
}
logger.info("LLM >>> model=%s system=%s prompt=%s",
self.model, json.dumps(system[:500]), json.dumps(prompt[:500]))
logger.info(
"LLM >>> model=%s system=%s prompt=%s",
self.model,
json.dumps(system[:500]),
json.dumps(prompt[:500]),
)
data = json.dumps(payload).encode()
req = urllib.request.Request(
self.api_url, data=data,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}"},
self.api_url,
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
},
)
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
@@ -48,18 +65,24 @@ class LLMClient:
usage = result.get("usage", {})
in_tokens = usage.get("prompt_tokens", 0)
out_tokens = usage.get("completion_tokens", 0)
cost = (in_tokens * self.input_cost_per_1m / 1_000_000) + \
(out_tokens * self.output_cost_per_1m / 1_000_000)
cost = (in_tokens * self.input_cost_per_1m / 1_000_000) + (
out_tokens * self.output_cost_per_1m / 1_000_000
)
self.total_cost += cost
self.total_calls += 1
self.total_in_tokens += in_tokens
self.total_out_tokens += out_tokens
return self.clean(result["choices"][0]["message"]["content"])
except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError, KeyError) as e:
except (
urllib.error.HTTPError,
urllib.error.URLError,
json.JSONDecodeError,
KeyError,
) as e:
logger.warning("LLM call attempt %d/3 failed: %s", attempt + 1, e)
if attempt == 2:
raise
time.sleep(2 ** attempt)
time.sleep(2**attempt)
return ""
@staticmethod
@@ -88,7 +111,9 @@ class LLMClient:
text = "\n".join(lines)
return text.strip()
def generate_post(self, title: str, desc: str, persona: str = "", category: str = "") -> str:
def generate_post(
self, title: str, desc: str, persona: str = "", category: str = ""
) -> str:
persona_extras = {
"enthusiastic_junior": "Be excited. Use **bold** for emphasis. Short excited sentences. End with a question sometimes.",
"grumpy_senior": "Be slightly cynical but helpful. Short blunt sentences. No fluff. Call out bad practices.",
@@ -107,9 +132,19 @@ class LLMClient:
"fun": "Write it lighthearted and playful, but stay tied to the actual tech topic. Humor about the technology itself, not off-topic jokes or lyrics.",
"random": "Be natural and conversational - share your thoughts like any casual discussion.",
}
persona_extra = f" {persona_extras.get(persona, 'Be casual. No markdown.')}" if persona else " Be casual. No markdown."
persona_extra = (
f" {persona_extras.get(persona, 'Be casual. No markdown.')}"
if persona
else " Be casual. No markdown."
)
category_extra = f" {category_extras.get(category, '')}" if category else ""
preserve = persona in ("enthusiastic_junior", "hobbyist_maker", "academic_type", "storyteller", "mentor")
preserve = persona in (
"enthusiastic_junior",
"hobbyist_maker",
"academic_type",
"storyteller",
"mentor",
)
text = self._call(
f"You are a dev writing a social media post reacting to tech news. Write 2-4 short paragraphs."
f"{persona_extra}{category_extra} Do not summarize the article; assume the reader already saw it. "
@@ -159,8 +194,13 @@ class LLMClient:
return emoji
return ""
def generate_comment(self, post_snippet: str, persona: str = "",
mention_target: str = "", parent_context: str = "") -> str:
def generate_comment(
self,
post_snippet: str,
persona: str = "",
mention_target: str = "",
parent_context: str = "",
) -> str:
extras = {
"enthusiastic_junior": "Be excited. Use **bold** for agreement. Short replies.",
"grumpy_senior": "Be blunt and short. No markdown. One sarcastic remark or actual advice.",
@@ -169,7 +209,11 @@ class LLMClient:
"storyteller": "Share a quick related story. Use *italics* for emphasis.",
"minimalist": "Shortest possible reply. One sentence max.",
}
extra = f" {extras.get(persona, 'Be casual. No markdown.')}" if persona else " Be casual. No markdown."
extra = (
f" {extras.get(persona, 'Be casual. No markdown.')}"
if persona
else " Be casual. No markdown."
)
preserve = persona in ("enthusiastic_junior", "mentor", "storyteller")
mention_rule = ""
if mention_target:
@@ -206,10 +250,16 @@ class LLMClient:
hits = sum(1 for w in candidate if w in source)
return hits / len(candidate)
def quality_check(self, kind: str, text: str, context: str = "") -> tuple[bool, str]:
def quality_check(
self, kind: str, text: str, context: str = ""
) -> tuple[bool, str]:
from devplacepy.services.bot.config import (
MIN_COMMENT_LEN, MIN_POST_LEN, RESTATEMENT_OVERLAP_THRESHOLD, GENERIC_COMMENT_PHRASES,
MIN_COMMENT_LEN,
MIN_POST_LEN,
RESTATEMENT_OVERLAP_THRESHOLD,
GENERIC_COMMENT_PHRASES,
)
stripped = self.clean(text or "").strip()
min_len = MIN_COMMENT_LEN if kind == "comment" else MIN_POST_LEN
if len(stripped) < min_len:
@@ -219,7 +269,10 @@ class LLMClient:
for phrase in GENERIC_COMMENT_PHRASES:
if phrase in lowered:
return False, f"generic phrase '{phrase}'"
if context and self._overlap_ratio(stripped, context) > RESTATEMENT_OVERLAP_THRESHOLD:
if (
context
and self._overlap_ratio(stripped, context) > RESTATEMENT_OVERLAP_THRESHOLD
):
return False, "restates the source"
verdict = self._call(
"You are a strict content quality reviewer for a developer community. "
@@ -240,7 +293,9 @@ class LLMClient:
)
lines = text.strip().split("\n")
title = lines[0].strip().lstrip("#").strip()[:120] or f"Bug report: {topic}"
desc = " ".join(line.lstrip("-* ").strip() for line in lines[1:] if line.strip())
desc = " ".join(
line.lstrip("-* ").strip() for line in lines[1:] if line.strip()
)
return title, desc[:2000] or text[:500]
def generate_bio(self) -> str:
@@ -250,10 +305,13 @@ class LLMClient:
)
def generate_profile_fields(self, handle: str) -> tuple[str, str, str]:
location = self.clean(self._call(
"Name one plausible city and country for a developer. Reply with ONLY 'City, Country'. No extra words.",
"Location:", temperature=0.9,
))[:80]
location = self.clean(
self._call(
"Name one plausible city and country for a developer. Reply with ONLY 'City, Country'. No extra words.",
"Location:",
temperature=0.9,
)
)[:80]
slug = re.sub(r"[^a-z0-9_-]", "", handle.lower()) or "dev"
git_link = f"https://github.com/{slug}"
website = f"https://{slug}.dev"
@@ -269,13 +327,19 @@ class LLMClient:
"minimalist": "One short sentence.",
}.get(persona, "Be casual and friendly.")
ctx = f"\n\nEarlier message:\n{context[:400]}" if context else ""
return self.clean(self._call(
f"Write one short, friendly direct message to another developer. 1-2 sentences. {extra} No em dashes.",
f"Write a DM to start or continue a chat.{ctx}",
))[:500]
return self.clean(
self._call(
f"Write one short, friendly direct message to another developer. 1-2 sentences. {extra} No em dashes.",
f"Write a DM to start or continue a chat.{ctx}",
)
)[:500]
def generate_project_title(self) -> str:
return self._call("Come up with a project name. 2-4 words. A tool, game, or app idea. No em dashes.", "Name:", temperature=0.9)
return self._call(
"Come up with a project name. 2-4 words. A tool, game, or app idea. No em dashes.",
"Name:",
temperature=0.9,
)
def generate_project_desc(self, title: str) -> str:
return self._call(
@@ -285,19 +349,25 @@ class LLMClient:
def generate_gist(self, persona: str = "") -> tuple[str, str, str, str]:
language = random.choice(GIST_LANGUAGES)
title = self.clean(self._call(
"Name a short, useful code snippet. 2-5 words. No quotes. No markdown.",
f"Language: {language}. Snippet name:",
temperature=0.9,
))[:120]
description = self.clean(self._call(
"Write a one-sentence description of what a code snippet does, like a dev sharing something handy. No em dashes.",
f"Snippet: {title}\nLanguage: {language}",
))[:400]
code = self.strip_code_fences(self._call(
f"Write a short, correct, self-contained {language} snippet of 5 to 20 lines for the description. "
"Output ONLY raw code. No markdown fences. No commentary.",
f"Title: {title}\nDescription: {description}\nLanguage: {language}",
temperature=0.4,
))[:4000]
title = self.clean(
self._call(
"Name a short, useful code snippet. 2-5 words. No quotes. No markdown.",
f"Language: {language}. Snippet name:",
temperature=0.9,
)
)[:120]
description = self.clean(
self._call(
"Write a one-sentence description of what a code snippet does, like a dev sharing something handy. No em dashes.",
f"Snippet: {title}\nLanguage: {language}",
)
)[:400]
code = self.strip_code_fences(
self._call(
f"Write a short, correct, self-contained {language} snippet of 5 to 20 lines for the description. "
"Output ONLY raw code. No markdown fences. No commentary.",
f"Title: {title}\nDescription: {description}\nLanguage: {language}",
temperature=0.4,
)
)[:4000]
return title, description, language, code
+6 -2
View File
@@ -15,11 +15,15 @@ class NewsFetcher:
self.fetched_at: float = 0.0
def fetch(self) -> list[dict]:
fresh = self.articles and (time.monotonic() - self.fetched_at) < self.TTL_SECONDS
fresh = (
self.articles and (time.monotonic() - self.fetched_at) < self.TTL_SECONDS
)
if fresh:
return self.articles
try:
req = urllib.request.Request(self.news_api, headers={"User-Agent": "dpbot/1.0"})
req = urllib.request.Request(
self.news_api, headers={"User-Agent": "dpbot/1.0"}
)
with urllib.request.urlopen(req, timeout=10) as resp:
articles = json.loads(resp.read()).get("articles", [])
if articles:
+3 -1
View File
@@ -42,7 +42,9 @@ class ArticleRegistry:
continue
ts = meta.get("time", "")
try:
age_days = (now - datetime.fromisoformat(ts)).total_seconds() / 86400
age_days = (
now - datetime.fromisoformat(ts)
).total_seconds() / 86400
except (ValueError, TypeError):
age_days = 0
if age_days < 7:
+124 -40
View File
@@ -21,30 +21,92 @@ class BotsService(BaseService):
"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"),
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):
@@ -56,6 +118,7 @@ class BotsService(BaseService):
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
@@ -69,7 +132,9 @@ class BotsService(BaseService):
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")
self.log(
"No LLM API key available (bot_api_key / gateway internal key); fleet idle"
)
return
try:
self._ensure_importable()
@@ -106,6 +171,7 @@ class BotsService(BaseService):
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"],
@@ -137,7 +203,9 @@ class BotsService(BaseService):
async def _stop_fleet(self) -> None:
slots = list(self._fleet)
if slots:
await asyncio.gather(*(self._stop_slot(slot) for slot in slots), return_exceptions=True)
await asyncio.gather(
*(self._stop_slot(slot) for slot in slots), return_exceptions=True
)
async def _stop_slot(self, slot: int) -> None:
entry = self._fleet.pop(slot, None)
@@ -187,18 +255,22 @@ class BotsService(BaseService):
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)
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"
@@ -217,12 +289,24 @@ class BotsService(BaseService):
{"label": "Votes", "value": votes},
]
table = {
"columns": ["Bot", "User", "Persona", "Status", "Posts", "Comments", "Votes", "Calls", "Cost"],
"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]:
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
+126 -41
View File
@@ -12,7 +12,13 @@ from devplacepy import config, project_files
from devplacepy.services.containers import store
from devplacepy.services.containers.backend.base import Mount, PortMapping, RunSpec
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.devii.tasks.schedule import Schedule, next_run, now_utc, to_iso, from_iso
from devplacepy.services.devii.tasks.schedule import (
Schedule,
next_run,
now_utc,
to_iso,
from_iso,
)
IMAGE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,62}$")
INGRESS_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
@@ -40,7 +46,9 @@ def parse_ports(value) -> list:
ports = []
if not value:
return ports
items = value if isinstance(value, list) else str(value).replace(",", "\n").splitlines()
items = (
value if isinstance(value, list) else str(value).replace(",", "\n").splitlines()
)
for item in items:
item = str(item).strip()
if not item:
@@ -53,7 +61,9 @@ def parse_ports(value) -> list:
else:
host, container = "0", item
if not host.isdigit() or not container.isdigit():
raise ContainerError(f"port '{item}' must be numeric host:container or a bare container port")
raise ContainerError(
f"port '{item}' must be numeric host:container or a bare container port"
)
ports.append(PortMapping(int(host), int(container), proto.strip() or "tcp"))
return ports
@@ -84,7 +94,9 @@ def allocate_host_port(reserved: set) -> int:
continue
if _host_port_free(port):
return port
raise ContainerError(f"no free host port available in range {HOST_PORT_MIN}-{HOST_PORT_MAX}")
raise ContainerError(
f"no free host port available in range {HOST_PORT_MIN}-{HOST_PORT_MAX}"
)
def assign_host_ports(port_list: list) -> list:
@@ -95,7 +107,9 @@ def assign_host_ports(port_list: list) -> list:
if not host:
host = allocate_host_port(reserved)
elif host in reserved:
raise ContainerError(f"host port {host} is already published by another instance")
raise ContainerError(
f"host port {host} is already published by another instance"
)
reserved.add(host)
assigned.append(PortMapping(host, mapping.container, mapping.proto))
return assigned
@@ -119,33 +133,55 @@ def parse_env(value) -> dict:
# ---------------- instances ----------------
def validate_ingress(slug: str, port, port_list) -> tuple:
slug = (slug or "").strip().lower()
if not slug:
return "", 0
if not INGRESS_SLUG_RE.match(slug):
raise ContainerError("ingress slug must be lowercase letters, digits, or '-' (max 63 chars)")
raise ContainerError(
"ingress slug must be lowercase letters, digits, or '-' (max 63 chars)"
)
for other in store.all_instances():
if other.get("ingress_slug") == slug:
raise ContainerError(f"ingress slug '{slug}' is already in use")
ingress_port = int(port) if port else 0
container_ports = {p.container for p in port_list}
if ingress_port and ingress_port not in container_ports:
raise ContainerError(f"ingress_port {ingress_port} must be one of the container ports you mapped")
raise ContainerError(
f"ingress_port {ingress_port} must be one of the container ports you mapped"
)
if not ingress_port and len(container_ports) != 1:
raise ContainerError("set ingress_port to choose which mapped container port to publish")
raise ContainerError(
"set ingress_port to choose which mapped container port to publish"
)
return slug, ingress_port
async def create_instance(project: dict, *, name: str,
boot_command: str = "", env="", cpu_limit: str = "", mem_limit: str = "",
ports="", volumes="", restart_policy: str = "never",
autostart: bool = True, ingress_slug: str = "", ingress_port=None,
actor=("system", "system")) -> dict:
async def create_instance(
project: dict,
*,
name: str,
boot_command: str = "",
env="",
cpu_limit: str = "",
mem_limit: str = "",
ports="",
volumes="",
restart_policy: str = "never",
autostart: bool = True,
ingress_slug: str = "",
ingress_port=None,
actor=("system", "system"),
) -> dict:
if not await get_backend().image_exists(config.CONTAINER_IMAGE):
raise ContainerError(f"the '{config.CONTAINER_IMAGE}' image is not built - run 'make ppy'")
raise ContainerError(
f"the '{config.CONTAINER_IMAGE}' image is not built - run 'make ppy'"
)
if restart_policy not in store.RESTART_POLICIES:
raise ContainerError(f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}")
raise ContainerError(
f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}"
)
_validate_limits(cpu_limit, mem_limit)
port_list = assign_host_ports(parse_ports(ports))
env_map = parse_env(env)
@@ -155,30 +191,50 @@ async def create_instance(project: dict, *, name: str,
ingress_slug, ingress_port = validate_ingress(ingress_slug, ingress_port, port_list)
workspace = Path(config.CONTAINER_WORKSPACES_DIR) / project["uid"]
await asyncio.to_thread(project_files.export_to_dir, project["uid"], "", str(workspace))
await asyncio.to_thread(
project_files.export_to_dir, project["uid"], "", str(workspace)
)
row = {
"project_uid": project["uid"],
"created_by": actor[1] if actor and actor[0] == "user" else "",
"owner_uid": project.get("user_uid", ""),
"name": name, "boot_command": boot_command or "",
"name": name,
"boot_command": boot_command or "",
"env_json": json.dumps(env_map),
"cpu_limit": str(cpu_limit or ""), "mem_limit": str(mem_limit or ""),
"ports_json": json.dumps([{"host": p.host, "container": p.container, "proto": p.proto} for p in port_list]),
"volumes_json": volumes if isinstance(volumes, str) else json.dumps(volumes or []),
"cpu_limit": str(cpu_limit or ""),
"mem_limit": str(mem_limit or ""),
"ports_json": json.dumps(
[
{"host": p.host, "container": p.container, "proto": p.proto}
for p in port_list
]
),
"volumes_json": volumes
if isinstance(volumes, str)
else json.dumps(volumes or []),
"restart_policy": restart_policy,
"ingress_slug": ingress_slug, "ingress_port": ingress_port,
"ingress_slug": ingress_slug,
"ingress_port": ingress_port,
"desired_state": store.DESIRED_RUNNING if autostart else store.DESIRED_STOPPED,
"status": store.ST_CREATED,
"workspace_dir": str(workspace),
}
instance = store.create_instance(row)
store.record_event(instance, "created", actor[0], actor[1], {"image": config.CONTAINER_IMAGE})
store.record_event(
instance, "created", actor[0], actor[1], {"image": config.CONTAINER_IMAGE}
)
return instance
def set_desired_state(instance: dict, desired: str, *, actor=("system", "system")) -> dict:
if desired not in (store.DESIRED_RUNNING, store.DESIRED_STOPPED, store.DESIRED_PAUSED):
def set_desired_state(
instance: dict, desired: str, *, actor=("system", "system")
) -> dict:
if desired not in (
store.DESIRED_RUNNING,
store.DESIRED_STOPPED,
store.DESIRED_PAUSED,
):
raise ContainerError("desired state must be running, stopped, or paused")
store.update_instance(instance["uid"], {"desired_state": desired})
store.record_event(instance, f"desire_{desired}", actor[0], actor[1])
@@ -186,18 +242,25 @@ def set_desired_state(instance: dict, desired: str, *, actor=("system", "system"
def request_restart(instance: dict, *, actor=("system", "system")) -> dict:
store.update_instance(instance["uid"], {"desired_state": store.DESIRED_RUNNING, "status": store.ST_RESTARTING})
store.update_instance(
instance["uid"],
{"desired_state": store.DESIRED_RUNNING, "status": store.ST_RESTARTING},
)
store.record_event(instance, "restart", actor[0], actor[1])
return store.get_instance(instance["uid"])
def mark_for_removal(instance: dict, *, actor=("system", "system")) -> None:
store.update_instance(instance["uid"], {"desired_state": store.DESIRED_STOPPED, "status": store.ST_REMOVING})
store.update_instance(
instance["uid"],
{"desired_state": store.DESIRED_STOPPED, "status": store.ST_REMOVING},
)
store.record_event(instance, "remove", actor[0], actor[1])
def pravda_env(instance: dict) -> dict:
from devplacepy import database, seo
base_url = seo.public_base_url()
api_key = ""
for uid in (instance.get("created_by"), instance.get("owner_uid")):
@@ -222,19 +285,32 @@ def pravda_env(instance: dict) -> dict:
def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
env = {**json.loads(instance.get("env_json") or "{}"), **pravda_env(instance)}
ports = [PortMapping(p["host"], p["container"], p.get("proto", "tcp"))
for p in json.loads(instance.get("ports_json") or "[]")]
ports = [
PortMapping(p["host"], p["container"], p.get("proto", "tcp"))
for p in json.loads(instance.get("ports_json") or "[]")
]
mounts = [Mount(instance["workspace_dir"], "/app", "rw")]
for extra in json.loads(instance.get("volumes_json") or "[]"):
if isinstance(extra, dict) and extra.get("host") and extra.get("container"):
mounts.append(Mount(extra["host"], extra["container"], extra.get("mode", "rw")))
mounts.append(
Mount(extra["host"], extra["container"], extra.get("mode", "rw"))
)
boot = (instance.get("boot_command") or "").strip()
command = ["/bin/sh", "-c", boot] if boot else ["sleep", "infinity"]
return RunSpec(
image=image_tag, name=instance["slug"],
labels={INSTANCE_LABEL: instance["uid"], PROJECT_LABEL: instance["project_uid"]},
env=env, cpu_limit=instance.get("cpu_limit", ""), mem_limit=instance.get("mem_limit", ""),
ports=ports, mounts=mounts, restart_policy=instance.get("restart_policy", "never"), command=command,
image=image_tag,
name=instance["slug"],
labels={
INSTANCE_LABEL: instance["uid"],
PROJECT_LABEL: instance["project_uid"],
},
env=env,
cpu_limit=instance.get("cpu_limit", ""),
mem_limit=instance.get("mem_limit", ""),
ports=ports,
mounts=mounts,
restart_policy=instance.get("restart_policy", "never"),
command=command,
)
@@ -242,7 +318,9 @@ async def sync_workspace(instance: dict, user: dict) -> int:
workspace = instance.get("workspace_dir")
if not workspace:
raise ContainerError("instance has no workspace")
count = await asyncio.to_thread(project_files.import_from_dir, instance["project_uid"], workspace, user)
count = await asyncio.to_thread(
project_files.import_from_dir, instance["project_uid"], workspace, user
)
store.record_event(instance, "sync", "user", user["uid"], {"imported": count})
return count
@@ -256,6 +334,7 @@ def add_schedule(instance: dict, action: str, schedule: Schedule) -> dict:
# ---------------- aggregation ----------------
def _percentile(values: list, pct: float) -> float:
if not values:
return 0.0
@@ -311,14 +390,20 @@ def instance_runtime(instance: dict) -> dict:
ports = []
for mapping in port_maps:
host_port = int(mapping.get("host") or 0)
ports.append({
"container": int(mapping.get("container") or 0),
"host": host_port,
"proto": mapping.get("proto", "tcp"),
"reachable": _port_reachable(host, host_port) if host_port else False,
})
ports.append(
{
"container": int(mapping.get("container") or 0),
"host": host_port,
"proto": mapping.get("proto", "tcp"),
"reachable": _port_reachable(host, host_port) if host_port else False,
}
)
ingress_host_port = _ingress_host_port(instance, port_maps)
ingress_serving = _http_probe(host, ingress_host_port) if ingress_host_port else "no ingress port mapped"
ingress_serving = (
_http_probe(host, ingress_host_port)
if ingress_host_port
else "no ingress port mapped"
)
return {
"command": boot or "image CMD (no boot_command set)",
"ports": ports,
+25 -6
View File
@@ -73,8 +73,15 @@ class PsRow:
class Backend(ABC):
@abstractmethod
async def build(self, *, context_dir: str, dockerfile_text: str, tags: list,
on_log: Optional[LogCallback] = None, network: str = "") -> BuildResult: ...
async def build(
self,
*,
context_dir: str,
dockerfile_text: str,
tags: list,
on_log: Optional[LogCallback] = None,
network: str = "",
) -> BuildResult: ...
@abstractmethod
async def run(self, spec: RunSpec) -> str: ...
@@ -98,12 +105,24 @@ class Backend(ABC):
async def rm(self, cid: str, force: bool = False) -> None: ...
@abstractmethod
async def exec(self, cid: str, cmd: list, *, tty: bool = False,
on_log: Optional[LogCallback] = None) -> ExecResult: ...
async def exec(
self,
cid: str,
cmd: list,
*,
tty: bool = False,
on_log: Optional[LogCallback] = None,
) -> ExecResult: ...
@abstractmethod
async def logs(self, cid: str, *, follow: bool = False, tail: int = 200,
on_log: Optional[LogCallback] = None) -> None: ...
async def logs(
self,
cid: str,
*,
follow: bool = False,
tail: int = 200,
on_log: Optional[LogCallback] = None,
) -> None: ...
@abstractmethod
async def stats_once(self, cids: list) -> dict: ...
@@ -51,8 +51,19 @@ def build_run_argv(spec: RunSpec) -> list:
return argv
def build_image_argv(context_dir: str, dockerfile_path: str, tags: list, iidfile: str, network: str = "") -> list:
argv = [DOCKER, "build", "--label", "devplace.build=1", "--iidfile", iidfile, "-f", dockerfile_path]
def build_image_argv(
context_dir: str, dockerfile_path: str, tags: list, iidfile: str, network: str = ""
) -> list:
argv = [
DOCKER,
"build",
"--label",
"devplace.build=1",
"--iidfile",
iidfile,
"-f",
dockerfile_path,
]
if network:
argv += ["--network", network]
for tag in tags:
@@ -67,7 +78,7 @@ def parse_size(text: str) -> int:
return 0
value = float(match.group(1))
unit = match.group(2).lower().rstrip("i")
factor = {"": 1, "k": 1024, "m": 1024 ** 2, "g": 1024 ** 3, "t": 1024 ** 4}.get(unit, 1)
factor = {"": 1, "k": 1024, "m": 1024**2, "g": 1024**3, "t": 1024**4}.get(unit, 1)
return int(value * factor)
@@ -84,30 +95,51 @@ def parse_stats_line(row: dict) -> StatsSample:
cpu_pct=float((row.get("CPUPerc", "0%") or "0%").rstrip("%") or 0),
mem_bytes=_pair(row.get("MemUsage", ""))[0],
mem_pct=float((row.get("MemPerc", "0%") or "0%").rstrip("%") or 0),
net_rx=rx, net_tx=tx, blk_read=rd, blk_write=wr,
net_rx=rx,
net_tx=tx,
blk_read=rd,
blk_write=wr,
)
class DockerCliBackend(Backend):
def __init__(self, binary: str = DOCKER, timeout: int = DEFAULT_TIMEOUT,
build_timeout: int = BUILD_TIMEOUT) -> None:
def __init__(
self,
binary: str = DOCKER,
timeout: int = DEFAULT_TIMEOUT,
build_timeout: int = BUILD_TIMEOUT,
) -> None:
self._binary = binary
self._timeout = timeout
self._build_timeout = build_timeout
async def _run(self, argv: list, *, timeout: Optional[int] = None) -> tuple:
proc = await asyncio.create_subprocess_exec(
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout or self._timeout)
out, err = await asyncio.wait_for(
proc.communicate(), timeout=timeout or self._timeout
)
except asyncio.TimeoutError:
proc.kill()
raise RuntimeError(f"docker command timed out: {' '.join(argv[:3])}")
return proc.returncode, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
return (
proc.returncode,
out.decode("utf-8", "replace"),
err.decode("utf-8", "replace"),
)
async def _stream(self, argv: list, on_log: Optional[LogCallback], *, timeout: Optional[int] = None) -> int:
async def _stream(
self,
argv: list,
on_log: Optional[LogCallback],
*,
timeout: Optional[int] = None,
) -> int:
proc = await asyncio.create_subprocess_exec(
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
)
async def pump():
assert proc.stdout is not None
@@ -126,13 +158,19 @@ class DockerCliBackend(Backend):
async def _check(self, argv: list) -> str:
code, out, err = await self._run(argv)
if code != 0:
raise RuntimeError(f"{' '.join(argv[:2])} failed ({code}): {(err or out).strip()[:500]}")
raise RuntimeError(
f"{' '.join(argv[:2])} failed ({code}): {(err or out).strip()[:500]}"
)
return out.strip()
async def build(self, *, context_dir, dockerfile_text, tags, on_log=None, network="") -> BuildResult:
async def build(
self, *, context_dir, dockerfile_text, tags, on_log=None, network=""
) -> BuildResult:
ctx = Path(context_dir)
dockerfile_path = ctx / "Dockerfile.devplace"
iid_path = await asyncio.to_thread(self._prepare_context, dockerfile_path, dockerfile_text)
iid_path = await asyncio.to_thread(
self._prepare_context, dockerfile_path, dockerfile_text
)
argv = build_image_argv(str(ctx), str(dockerfile_path), tags, iid_path, network)
try:
code = await self._stream(argv, on_log, timeout=self._build_timeout)
@@ -220,7 +258,15 @@ class DockerCliBackend(Backend):
return samples
async def ps(self, *, label_filter: str = "devplace.instance") -> list:
argv = [self._binary, "ps", "-a", "--filter", f"label={label_filter}", "--format", "{{json .}}"]
argv = [
self._binary,
"ps",
"-a",
"--filter",
f"label={label_filter}",
"--format",
"{{json .}}",
]
code, out, _ = await self._run(argv, timeout=30)
rows = []
if code != 0:
@@ -240,14 +286,16 @@ class DockerCliBackend(Backend):
labels[key] = value
status = row.get("Status", "") or ""
match = _EXIT_RE.search(status)
rows.append(PsRow(
container_id=row.get("ID", ""),
name=row.get("Names", ""),
state=(row.get("State", "") or "").lower(),
status=status,
exit_code=int(match.group(1)) if match else None,
labels=labels,
))
rows.append(
PsRow(
container_id=row.get("ID", ""),
name=row.get("Names", ""),
state=(row.get("State", "") or "").lower(),
status=status,
exit_code=int(match.group(1)) if match else None,
labels=labels,
)
)
return rows
async def inspect(self, cid: str) -> dict:
@@ -262,7 +310,17 @@ class DockerCliBackend(Backend):
async def image_prune(self) -> None:
try:
await self._run([self._binary, "image", "prune", "-f", "--filter", "label=devplace.build"], timeout=120)
await self._run(
[
self._binary,
"image",
"prune",
"-f",
"--filter",
"label=devplace.build",
],
timeout=120,
)
except RuntimeError as exc:
logger.warning("image prune failed: %s", exc)
@@ -276,5 +334,7 @@ class DockerCliBackend(Backend):
logger.warning("rmi %s failed: %s", ref, (err or "").strip()[:200])
async def image_exists(self, ref: str) -> bool:
code, _, _ = await self._run([self._binary, "image", "inspect", ref], timeout=30)
code, _, _ = await self._run(
[self._binary, "image", "inspect", ref], timeout=30
)
return code == 0
+27 -10
View File
@@ -22,20 +22,28 @@ class FakeBackend(Backend):
self._counter = 0
self.fail_build = False
async def build(self, *, context_dir, dockerfile_text, tags, on_log=None, network="") -> BuildResult:
async def build(
self, *, context_dir, dockerfile_text, tags, on_log=None, network=""
) -> BuildResult:
if on_log is not None:
await on_log(f"FAKE build {tags} net={network}")
if self.fail_build:
return BuildResult(success=False, error="fake build failure")
self.built_images.append(list(tags))
return BuildResult(success=True, image_id=f"sha256:fake{len(self.built_images)}")
return BuildResult(
success=True, image_id=f"sha256:fake{len(self.built_images)}"
)
async def run(self, spec: RunSpec) -> str:
self._counter += 1
cid = f"fake{self._counter:012d}"
self.containers[cid] = {
"name": spec.name, "state": "running", "labels": dict(spec.labels),
"exit_code": None, "image": spec.image, "spec": spec,
"name": spec.name,
"state": "running",
"labels": dict(spec.labels),
"exit_code": None,
"image": spec.image,
"spec": spec,
}
return cid
@@ -75,8 +83,11 @@ class FakeBackend(Backend):
await on_log(f"FAKE logs for {cid}")
async def stats_once(self, cids: list) -> dict:
return {self.containers[cid]["name"]: StatsSample(cpu_pct=1.0, mem_bytes=1024)
for cid in cids if cid in self.containers}
return {
self.containers[cid]["name"]: StatsSample(cpu_pct=1.0, mem_bytes=1024)
for cid in cids
if cid in self.containers
}
async def ps(self, *, label_filter: str = "devplace.instance") -> list:
key, _, value = label_filter.partition("=")
@@ -87,10 +98,16 @@ class FakeBackend(Backend):
continue
if value and labels.get(key) != value:
continue
rows.append(PsRow(
container_id=cid, name=data["name"], state=data["state"],
status=data["state"], exit_code=data["exit_code"], labels=labels,
))
rows.append(
PsRow(
container_id=cid,
name=data["name"],
state=data["state"],
status=data["state"],
exit_code=data["exit_code"],
labels=labels,
)
)
return rows
async def inspect(self, cid: str) -> dict:
@@ -7,6 +7,7 @@ def get_backend():
global _backend
if _backend is None:
from devplacepy.services.containers.backend.docker_cli import DockerCliBackend
_backend = DockerCliBackend()
return _backend
+86 -24
View File
@@ -21,9 +21,16 @@ class ContainerService(BaseService):
"docker socket."
)
config_fields = [
ConfigField("container_metrics_every", "Metrics sample every (ticks)", type="int",
default=1, minimum=1, maximum=60,
help="Sample docker stats once every N reconcile ticks.", group="Containers"),
ConfigField(
"container_metrics_every",
"Metrics sample every (ticks)",
type="int",
default=1,
minimum=1,
maximum=60,
help="Sample docker stats once every N reconcile ticks.",
group="Containers",
),
]
def __init__(self):
@@ -64,7 +71,11 @@ class ContainerService(BaseService):
await self._fire_schedules()
self._metric_tick += 1
if self._metric_tick % max(1, self.get_config().get("container_metrics_every", 1)) == 0:
if (
self._metric_tick
% max(1, self.get_config().get("container_metrics_every", 1))
== 0
):
await self._sample_metrics(backend, by_uid)
async def _reconcile(self, backend, inst, ps) -> None:
@@ -84,8 +95,14 @@ class ContainerService(BaseService):
await self._launch(backend, inst)
elif ps.state == "running":
if status != store.ST_RUNNING:
store.update_instance(uid, {"status": store.ST_RUNNING, "container_id": ps.container_id,
"started_at": inst.get("started_at") or store.now()})
store.update_instance(
uid,
{
"status": store.ST_RUNNING,
"container_id": ps.container_id,
"started_at": inst.get("started_at") or store.now(),
},
)
elif ps.state == "paused":
await backend.unpause(ps.container_id)
store.update_instance(uid, {"status": store.ST_RUNNING})
@@ -99,7 +116,9 @@ class ContainerService(BaseService):
if ps is not None and ps.state in ("running", "restarting", "paused"):
await backend.stop(ps.container_id)
if status != store.ST_STOPPED:
store.update_instance(uid, {"status": store.ST_STOPPED, "stopped_at": store.now()})
store.update_instance(
uid, {"status": store.ST_STOPPED, "stopped_at": store.now()}
)
elif desired == store.DESIRED_PAUSED:
if ps is not None and ps.state == "running":
@@ -112,10 +131,19 @@ class ContainerService(BaseService):
cid = await backend.run(spec)
except Exception as exc:
store.update_instance(inst["uid"], {"status": store.ST_CRASHED})
store.record_event(inst, "launch_failed", "reconciler", "", {"reason": str(exc)})
store.record_event(
inst, "launch_failed", "reconciler", "", {"reason": str(exc)}
)
self.log(f"launch {inst['name']} failed: {exc}")
return
store.update_instance(inst["uid"], {"container_id": cid, "status": store.ST_RUNNING, "started_at": store.now()})
store.update_instance(
inst["uid"],
{
"container_id": cid,
"status": store.ST_RUNNING,
"started_at": store.now(),
},
)
store.record_event(inst, "start", "reconciler", "")
self.log(f"launched instance {inst['name']}")
@@ -139,18 +167,37 @@ class ContainerService(BaseService):
if policy in AUTO_RESTART and not (policy == "on-failure" and exit_code == 0):
try:
await backend.start(ps.container_id)
store.update_instance(uid, {"status": store.ST_RUNNING,
"restart_count": int(inst.get("restart_count") or 0) + 1})
store.record_event(inst, "policy_restart", "reconciler", "",
{"exit_code": exit_code, "logs": logs})
store.update_instance(
uid,
{
"status": store.ST_RUNNING,
"restart_count": int(inst.get("restart_count") or 0) + 1,
},
)
store.record_event(
inst,
"policy_restart",
"reconciler",
"",
{"exit_code": exit_code, "logs": logs},
)
return
except Exception as exc:
self.log(f"policy restart {inst['name']} failed: {exc}")
terminal = store.ST_CRASHED if exit_code != 0 else store.ST_STOPPED
store.update_instance(uid, {"status": terminal, "desired_state": store.DESIRED_STOPPED,
"exit_code": exit_code, "stopped_at": store.now()})
store.update_instance(
uid,
{
"status": terminal,
"desired_state": store.DESIRED_STOPPED,
"exit_code": exit_code,
"stopped_at": store.now(),
},
)
if terminal == store.ST_CRASHED:
store.record_event(inst, "crash", "reconciler", "", {"exit_code": exit_code, "logs": logs})
store.record_event(
inst, "crash", "reconciler", "", {"exit_code": exit_code, "logs": logs}
)
async def _fire_schedules(self) -> None:
now = now_utc()
@@ -161,12 +208,16 @@ class ContainerService(BaseService):
store.delete_schedule(sched["uid"])
continue
action = sched["action"]
desired = store.DESIRED_RUNNING if action == "start" else store.DESIRED_STOPPED
desired = (
store.DESIRED_RUNNING if action == "start" else store.DESIRED_STOPPED
)
store.update_instance(inst["uid"], {"desired_state": desired})
store.record_event(inst, f"schedule_{action}", "scheduler", "")
cols = json.loads(sched.get("schedule_json") or "{}")
run_count = int(sched.get("run_count") or 0) + 1
upcoming = next_run(cols.get("kind"), cols.get("every_seconds"), cols.get("cron"), now)
upcoming = next_run(
cols.get("kind"), cols.get("every_seconds"), cols.get("cron"), now
)
changes = {"last_run_at": now_iso, "run_count": run_count}
max_runs = cols.get("max_runs")
if upcoming is None or (max_runs and run_count >= max_runs):
@@ -182,7 +233,9 @@ class ContainerService(BaseService):
if not running:
return
try:
samples = await backend.stats_once([row.container_id for row in running.values()])
samples = await backend.stats_once(
[row.container_id for row in running.values()]
)
except Exception as exc:
self.log(f"docker stats failed: {exc}")
return
@@ -205,9 +258,18 @@ class ContainerService(BaseService):
{"label": "Crashed", "value": counts.get(store.ST_CRASHED, 0)},
{"label": "Paused", "value": counts.get(store.ST_PAUSED, 0)},
]
rows = [[
inst.get("name", "")[:32], inst.get("status", ""), inst.get("desired_state", ""),
inst.get("restart_policy", ""), int(inst.get("restart_count") or 0),
] for inst in instances[:15]]
table = {"columns": ["Instance", "Status", "Desired", "Policy", "Restarts"], "rows": rows}
rows = [
[
inst.get("name", "")[:32],
inst.get("status", ""),
inst.get("desired_state", ""),
inst.get("restart_policy", ""),
int(inst.get("restart_count") or 0),
]
for inst in instances[:15]
]
table = {
"columns": ["Instance", "Status", "Desired", "Policy", "Restarts"],
"rows": rows,
}
return {"stats": stats, "table": table}
+84 -33
View File
@@ -34,13 +34,22 @@ def _exists(table: str) -> bool:
# ---------------- instances ----------------
def create_instance(row: dict) -> dict:
uid = generate_uid()
base = {
"uid": uid, "slug": make_combined_slug(row.get("name", "instance"), uid),
"container_id": "", "status": ST_CREATED, "exit_code": 0, "restart_count": 0,
"ingress_slug": "", "ingress_port": 0,
"started_at": "", "stopped_at": "", "created_at": now(), "updated_at": now(),
"uid": uid,
"slug": make_combined_slug(row.get("name", "instance"), uid),
"container_id": "",
"status": ST_CREATED,
"exit_code": 0,
"restart_count": 0,
"ingress_slug": "",
"ingress_port": 0,
"started_at": "",
"stopped_at": "",
"created_at": now(),
"updated_at": now(),
}
base.update(row)
base["uid"] = uid
@@ -61,8 +70,11 @@ def list_instances(project_uid: str = None) -> list:
filters = {}
if project_uid:
filters["project_uid"] = project_uid
return sorted(get_table("instances").find(**filters),
key=lambda r: r.get("created_at", ""), reverse=True)
return sorted(
get_table("instances").find(**filters),
key=lambda r: r.get("created_at", ""),
reverse=True,
)
def all_instances() -> list:
@@ -91,52 +103,86 @@ def delete_instance(uid: str) -> None:
# ---------------- events / metrics / schedules ----------------
def record_event(instance: dict, event: str, actor_kind: str, actor_id: str, detail: dict = None) -> None:
get_table("instance_events").insert({
"uid": generate_uid(), "instance_uid": instance["uid"], "project_uid": instance.get("project_uid", ""),
"event": event, "actor_kind": actor_kind, "actor_id": actor_id or "",
"detail": json.dumps(detail or {}), "created_at": now(),
})
def record_event(
instance: dict, event: str, actor_kind: str, actor_id: str, detail: dict = None
) -> None:
get_table("instance_events").insert(
{
"uid": generate_uid(),
"instance_uid": instance["uid"],
"project_uid": instance.get("project_uid", ""),
"event": event,
"actor_kind": actor_kind,
"actor_id": actor_id or "",
"detail": json.dumps(detail or {}),
"created_at": now(),
}
)
def list_events(instance_uid: str, limit: int = 100) -> list:
if not _exists("instance_events"):
return []
rows = sorted(get_table("instance_events").find(instance_uid=instance_uid),
key=lambda r: r.get("created_at", ""), reverse=True)
rows = sorted(
get_table("instance_events").find(instance_uid=instance_uid),
key=lambda r: r.get("created_at", ""),
reverse=True,
)
return rows[:limit]
def insert_metric(instance_uid: str, sample) -> None:
table = get_table("instance_metrics")
table.insert({
"uid": generate_uid(), "instance_uid": instance_uid, "ts": now(),
"cpu_pct": sample.cpu_pct, "mem_bytes": sample.mem_bytes, "mem_pct": sample.mem_pct,
"net_rx": sample.net_rx, "net_tx": sample.net_tx,
"blk_read": sample.blk_read, "blk_write": sample.blk_write,
})
table.insert(
{
"uid": generate_uid(),
"instance_uid": instance_uid,
"ts": now(),
"cpu_pct": sample.cpu_pct,
"mem_bytes": sample.mem_bytes,
"mem_pct": sample.mem_pct,
"net_rx": sample.net_rx,
"net_tx": sample.net_tx,
"blk_read": sample.blk_read,
"blk_write": sample.blk_write,
}
)
rows = sorted(table.find(instance_uid=instance_uid), key=lambda r: r.get("ts", ""))
if len(rows) > METRICS_RING:
for old in rows[:len(rows) - METRICS_RING]:
for old in rows[: len(rows) - METRICS_RING]:
table.delete(uid=old["uid"])
def recent_metrics(instance_uid: str, limit: int = 120) -> list:
if not _exists("instance_metrics"):
return []
rows = sorted(get_table("instance_metrics").find(instance_uid=instance_uid),
key=lambda r: r.get("ts", ""))
rows = sorted(
get_table("instance_metrics").find(instance_uid=instance_uid),
key=lambda r: r.get("ts", ""),
)
return rows[-limit:]
def create_schedule(instance: dict, action: str, schedule_columns: dict, next_run_at: str) -> dict:
def create_schedule(
instance: dict, action: str, schedule_columns: dict, next_run_at: str
) -> dict:
uid = generate_uid()
get_table("instance_schedules").insert({
"uid": uid, "instance_uid": instance["uid"], "project_uid": instance.get("project_uid", ""),
"action": action, "schedule_json": json.dumps(schedule_columns), "enabled": 1,
"next_run_at": next_run_at, "last_run_at": "", "run_count": 0,
"created_at": now(), "updated_at": now(),
})
get_table("instance_schedules").insert(
{
"uid": uid,
"instance_uid": instance["uid"],
"project_uid": instance.get("project_uid", ""),
"action": action,
"schedule_json": json.dumps(schedule_columns),
"enabled": 1,
"next_run_at": next_run_at,
"last_run_at": "",
"run_count": 0,
"created_at": now(),
"updated_at": now(),
}
)
return get_table("instance_schedules").find_one(uid=uid)
@@ -149,12 +195,17 @@ def list_schedules(instance_uid: str) -> list:
def due_schedules(now_iso: str) -> list:
if not _exists("instance_schedules"):
return []
return [r for r in get_table("instance_schedules").find(enabled=1)
if r.get("next_run_at") and r["next_run_at"] <= now_iso]
return [
r
for r in get_table("instance_schedules").find(enabled=1)
if r.get("next_run_at") and r["next_run_at"] <= now_iso
]
def update_schedule(uid: str, changes: dict) -> None:
get_table("instance_schedules").update({"uid": uid, "updated_at": now(), **changes}, ["uid"])
get_table("instance_schedules").update(
{"uid": uid, "updated_at": now(), **changes}, ["uid"]
)
def delete_schedule(uid: str) -> None:
@@ -5,8 +5,16 @@ from __future__ import annotations
from .spec import Action, Param
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
return Param(name=name, location="body", description=description, required=required, type=kind)
def arg(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
DEVII = (
@@ -31,7 +39,8 @@ AVATAR_ACTIONS: tuple[Action, ...] = (
path="",
summary="Hide devii from the screen (plays a goodbye/hide animation)",
description=(
DEVII + " This already plays a goodbye animation, so do not queue a separate "
DEVII
+ " This already plays a goodbye animation, so do not queue a separate "
"wave/goodbye animation immediately before calling it - hiding interrupts a still-"
"queued animation."
),
@@ -66,7 +75,8 @@ AVATAR_ACTIONS: tuple[Action, ...] = (
path="",
summary="Play a specific named animation (use avatar_list_animations first)",
description=(
DEVII + " A following avatar_hide or avatar_stop interrupts an animation that is "
DEVII
+ " A following avatar_hide or avatar_stop interrupts an animation that is "
"still playing, so do not hide immediately after if you want it to finish."
),
handler="avatar",
@@ -144,6 +154,12 @@ AVATAR_ACTIONS: tuple[Action, ...] = (
description=DEVII,
handler="avatar",
requires_auth=False,
params=(arg("name", "Character name (e.g. Clippy, Merlin, Bonzi, Genie, Rover).", required=True),),
params=(
arg(
"name",
"Character name (e.g. Clippy, Merlin, Bonzi, Genie, Rover).",
required=True,
),
),
),
)
+115 -24
View File
@@ -10,7 +10,9 @@ def path(name: str, description: str, required: bool = True) -> Param:
def query(name: str, description: str, required: bool = False) -> Param:
return Param(name=name, location="query", description=description, required=required)
return Param(
name=name, location="query", description=description, required=required
)
def body(name: str, description: str, required: bool = False) -> Param:
@@ -110,7 +112,10 @@ ACTIONS: tuple[Action, ...] = (
body("project_uid", "Attach the post to a project uid."),
body("attachment_uids", ATTACHMENTS),
body("poll_question", "Optional poll question."),
body("poll_options", "Poll options as a JSON array of strings, or one option per line, or comma separated. At least two are required for the poll to be created."),
body(
"poll_options",
"Poll options as a JSON array of strings, or one option per line, or comma separated. At least two are required for the poll to be created.",
),
),
),
Action(
@@ -118,7 +123,12 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/posts/{post_slug}",
summary="View a single post by slug",
params=(path("post_slug", "Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title."),),
params=(
path(
"post_slug",
"Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title.",
),
),
),
Action(
name="edit_post",
@@ -126,12 +136,21 @@ ACTIONS: tuple[Action, ...] = (
path="/posts/edit/{post_slug}",
summary="Edit an existing post",
params=(
path("post_slug", "Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title."),
path(
"post_slug",
"Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title.",
),
body("content", "Updated post body.", required=True),
body("title", "Updated title."),
body("topic", "Updated topic."),
body("poll_question", "Optional poll question. Adds a poll to a post that does not already have one."),
body("poll_options", "Poll options as a JSON array of strings, or one option per line, or comma separated. At least two are required for the poll to be created."),
body(
"poll_question",
"Optional poll question. Adds a poll to a post that does not already have one.",
),
body(
"poll_options",
"Poll options as a JSON array of strings, or one option per line, or comma separated. At least two are required for the poll to be created.",
),
),
),
Action(
@@ -139,7 +158,12 @@ ACTIONS: tuple[Action, ...] = (
method="POST",
path="/posts/delete/{post_slug}",
summary="Delete a post",
params=(path("post_slug", "Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title."),),
params=(
path(
"post_slug",
"Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title.",
),
),
),
Action(
name="create_comment",
@@ -180,7 +204,12 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/projects/{project_slug}",
summary="View a project by slug",
params=(path("project_slug", "Exact project slug copied from a /projects/... link in a listing response; do not build it from the title."),),
params=(
path(
"project_slug",
"Exact project slug copied from a /projects/... link in a listing response; do not build it from the title.",
),
),
),
Action(
name="create_project",
@@ -203,7 +232,12 @@ ACTIONS: tuple[Action, ...] = (
method="POST",
path="/projects/delete/{project_slug}",
summary="Delete a project",
params=(path("project_slug", "Exact project slug copied from a /projects/... link in a listing response; do not build it from the title."),),
params=(
path(
"project_slug",
"Exact project slug copied from a /projects/... link in a listing response; do not build it from the title.",
),
),
),
Action(
name="project_set_private",
@@ -218,8 +252,16 @@ ACTIONS: tuple[Action, ...] = (
),
params=(
path("project_slug", "Project slug or uid."),
body("value", "true to make the project private, false to make it public.", required=True),
body("confirm", "Must be true, set only after the user has explicitly confirmed.", required=True),
body(
"value",
"true to make the project private, false to make it public.",
required=True,
),
body(
"confirm",
"Must be true, set only after the user has explicitly confirmed.",
required=True,
),
),
),
Action(
@@ -236,8 +278,16 @@ ACTIONS: tuple[Action, ...] = (
),
params=(
path("project_slug", "Project slug or uid."),
body("value", "true to make the project read-only, false to make it writable.", required=True),
body("confirm", "Must be true, set only after the user has explicitly confirmed.", required=True),
body(
"value",
"true to make the project read-only, false to make it writable.",
required=True,
),
body(
"confirm",
"Must be true, set only after the user has explicitly confirmed.",
required=True,
),
),
),
Action(
@@ -257,7 +307,11 @@ ACTIONS: tuple[Action, ...] = (
description="Returns the file metadata plus the text content. Binary files return a url instead of content.",
params=(
path("project_slug", "Project slug or uid."),
query("path", "Relative file path inside the project, e.g. src/main.py.", required=True),
query(
"path",
"Relative file path inside the project, e.g. src/main.py.",
required=True,
),
),
requires_auth=False,
),
@@ -328,7 +382,11 @@ ACTIONS: tuple[Action, ...] = (
params=(
path("project_slug", "Project slug or uid."),
body("path", "Relative file path.", required=True),
body("at", "Insert before this 1-indexed line (1 prepends, total+1 appends).", required=True),
body(
"at",
"Insert before this 1-indexed line (1 prepends, total+1 appends).",
required=True,
),
body("content", "Text to insert.", required=True),
),
),
@@ -374,7 +432,9 @@ ACTIONS: tuple[Action, ...] = (
summary="Create a directory (and parents) in a project filesystem",
params=(
path("project_slug", "Project slug or uid."),
body("path", "Relative directory path, e.g. src/components.", required=True),
body(
"path", "Relative directory path, e.g. src/components.", required=True
),
),
),
Action(
@@ -558,8 +618,15 @@ ACTIONS: tuple[Action, ...] = (
ajax=True,
params=(
path("target_type", TARGET_TYPE),
path("target_uid", "Uid of the target, copied from a listing response; do not invent it."),
body("value", "Vote value: 1 to upvote, -1 to downvote (re-send to remove).", required=True),
path(
"target_uid",
"Uid of the target, copied from a listing response; do not invent it.",
),
body(
"value",
"Vote value: 1 to upvote, -1 to downvote (re-send to remove).",
required=True,
),
),
),
Action(
@@ -571,7 +638,10 @@ ACTIONS: tuple[Action, ...] = (
ajax=True,
params=(
path("target_type", TARGET_TYPE),
path("target_uid", "Uid of the target, copied from a listing response; do not invent it."),
path(
"target_uid",
"Uid of the target, copied from a listing response; do not invent it.",
),
body("emoji", "One of the allowed reaction emoji.", required=True),
),
),
@@ -591,7 +661,10 @@ ACTIONS: tuple[Action, ...] = (
ajax=True,
params=(
path("target_type", TARGET_TYPE),
path("target_uid", "Uid of the target, copied from a listing response; do not invent it."),
path(
"target_uid",
"Uid of the target, copied from a listing response; do not invent it.",
),
),
),
Action(
@@ -681,7 +754,12 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/gists/{gist_slug}",
summary="View a gist by slug",
params=(path("gist_slug", "Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title."),),
params=(
path(
"gist_slug",
"Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title.",
),
),
),
Action(
name="create_gist",
@@ -702,7 +780,10 @@ ACTIONS: tuple[Action, ...] = (
path="/gists/edit/{gist_slug}",
summary="Edit a gist",
params=(
path("gist_slug", "Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title."),
path(
"gist_slug",
"Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title.",
),
body("title", "Gist title.", required=True),
body("source_code", "Gist source code.", required=True),
body("description", "Gist description."),
@@ -714,7 +795,12 @@ ACTIONS: tuple[Action, ...] = (
method="POST",
path="/gists/delete/{gist_slug}",
summary="Delete a gist",
params=(path("gist_slug", "Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title."),),
params=(
path(
"gist_slug",
"Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title.",
),
),
),
Action(
name="list_news",
@@ -728,7 +814,12 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/news/{news_slug}",
summary="View a news article by slug",
params=(path("news_slug", "Exact news slug copied from a /news/... link in a listing response; do not build it from the title."),),
params=(
path(
"news_slug",
"Exact news slug copied from a /news/... link in a listing response; do not build it from the title.",
),
),
),
Action(
name="upload_file",
@@ -5,8 +5,16 @@ from __future__ import annotations
from .spec import Action, Param
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
return Param(name=name, location="body", description=description, required=required, type=kind)
def arg(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
CLIENT = (
@@ -20,7 +28,8 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
method="LOCAL",
path="",
summary="Read the user's current page: URL, title, viewport, scroll, selected text, visible headings, and whether they are signed in",
description=CLIENT + " Use this to understand where the user is and what they are looking at before acting or guiding them.",
description=CLIENT
+ " Use this to understand where the user is and what they are looking at before acting or guiding them.",
handler="client",
requires_auth=False,
read_only=True,
@@ -31,7 +40,8 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
path="",
summary="Execute JavaScript in the user's browser and return its result",
description=(
CLIENT + " The code is the body of an async function; use 'return value' to return a "
CLIENT
+ " The code is the body of an async function; use 'return value' to return a "
"JSON-serializable result. You have full access to window and document. Use this for "
"anything not covered by the dedicated tools: read or change the DOM, drive a live "
"demo, inspect state, or update the screen. Prefer the dedicated tools "
@@ -40,7 +50,11 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
handler="client",
requires_auth=False,
params=(
arg("code", "JavaScript to run as an async function body. Return a JSON-serializable value.", required=True),
arg(
"code",
"JavaScript to run as an async function body. Return a JSON-serializable value.",
required=True,
),
),
),
Action(
@@ -48,11 +62,16 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
method="LOCAL",
path="",
summary="Highlight an element on screen with an outline and an optional callout label, for live tutorials",
description=CLIENT + " Scrolls the element into view and draws an attention outline. Call clear_highlights to remove it.",
description=CLIENT
+ " Scrolls the element into view and draws an attention outline. Call clear_highlights to remove it.",
handler="client",
requires_auth=False,
params=(
arg("selector", "CSS selector, or the element's exact visible text (e.g. a heading or link label).", required=True),
arg(
"selector",
"CSS selector, or the element's exact visible text (e.g. a heading or link label).",
required=True,
),
arg("label", "Optional callout text shown next to the element."),
),
),
@@ -75,7 +94,11 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
params=(
arg("text", "Message to display.", required=True),
arg("duration_ms", "How long to show it, in milliseconds (default 4000).", kind="integer"),
arg(
"duration_ms",
"How long to show it, in milliseconds (default 4000).",
kind="integer",
),
),
),
Action(
@@ -86,7 +109,13 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
description=CLIENT,
handler="client",
requires_auth=False,
params=(arg("selector", "CSS selector, or the element's exact visible text.", required=True),),
params=(
arg(
"selector",
"CSS selector, or the element's exact visible text.",
required=True,
),
),
),
Action(
name="navigate_to",
@@ -94,7 +123,8 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
path="",
summary="Send the user's browser to a URL",
description=(
CLIENT + " Use a same-origin path like /feed or /docs/index.html, or a full URL. The "
CLIENT
+ " Use a same-origin path like /feed or /docs/index.html, or a full URL. The "
"page reloads; the user's Devii session and conversation persist and reconnect automatically."
),
handler="client",
@@ -106,7 +136,8 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
method="LOCAL",
path="",
summary="Refresh the user's current page, e.g. after something changed",
description=CLIENT + " The Devii session and conversation persist and reconnect automatically.",
description=CLIENT
+ " The Devii session and conversation persist and reconnect automatically.",
handler="client",
requires_auth=False,
),
@@ -116,14 +147,19 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
path="",
summary="Open a floating interactive terminal window attached to a running container instance",
description=(
CLIENT + " Opens a new xterm.js window in the user's browser connected to the container's "
CLIENT
+ " Opens a new xterm.js window in the user's browser connected to the container's "
"interactive shell (admin only). Resolve the instance first with container_list_instances, "
"then pass the project slug and the instance slug or uid."
),
handler="client",
requires_admin=True,
params=(
arg("project_slug", "Project slug or uid that owns the container.", required=True),
arg(
"project_slug",
"Project slug or uid that owns the container.",
required=True,
),
arg("instance", "Container instance slug or uid.", required=True),
arg("label", "Optional window title (defaults to the instance name)."),
),
@@ -3,51 +3,93 @@
from .spec import Action, Param
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
return Param(name=name, location="body", description=description, required=required, type=kind)
def arg(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
SLUG = arg("project_slug", "Project slug or uid that owns the container resources.", required=True)
SLUG = arg(
"project_slug",
"Project slug or uid that owns the container resources.",
required=True,
)
CONTAINER_ACTIONS: tuple[Action, ...] = (
Action(
name="container_list_instances",
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
method="LOCAL",
path="",
handler="container",
requires_admin=True,
read_only=True,
summary="List a project's container instances and their status",
params=(SLUG,),
),
Action(
name="container_create_instance",
method="LOCAL", path="", handler="container", requires_admin=True,
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Create and start a container instance (runs the shared ppy image with the project files mounted at /app)",
params=(
SLUG,
arg("name", "Instance name.", required=True),
arg("boot_command", "Optional command to run on boot, e.g. 'python app.py'."),
arg(
"boot_command", "Optional command to run on boot, e.g. 'python app.py'."
),
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
arg("env", "Optional env vars as KEY=VALUE lines."),
arg("ports", "Port maps per line or comma separated. Use a bare container port (e.g. '8899') to auto-assign a unique host port above 20000, or 'host:container' to pin one."),
arg(
"ports",
"Port maps per line or comma separated. Use a bare container port (e.g. '8899') to auto-assign a unique host port above 20000, or 'host:container' to pin one.",
),
arg("cpu_limit", "Optional CPU limit, e.g. 1 or 1.5."),
arg("mem_limit", "Optional memory limit, e.g. 512m or 1g."),
arg("autostart", "Start immediately ('true' or 'false', default true)."),
arg("ingress_slug", "Optional public ingress slug; the service is then reachable at /p/<slug>."),
arg("ingress_port", "Container port to publish at /p/<slug> (must be one of the mapped ports).", kind="integer"),
arg(
"ingress_slug",
"Optional public ingress slug; the service is then reachable at /p/<slug>.",
),
arg(
"ingress_port",
"Container port to publish at /p/<slug> (must be one of the mapped ports).",
kind="integer",
),
),
),
Action(
name="container_instance_action",
method="LOCAL", path="", handler="container", requires_admin=True,
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Control an instance: start, stop, restart, pause, resume, delete, or sync",
description="sync imports the container /app workspace back into the project files.",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg("action", "start, stop, restart, pause, resume, delete, or sync.", required=True),
arg(
"action",
"start, stop, restart, pause, resume, delete, or sync.",
required=True,
),
),
),
Action(
name="container_logs",
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
method="LOCAL",
path="",
handler="container",
requires_admin=True,
read_only=True,
summary="Read the recent logs of a running instance",
params=(
SLUG,
@@ -57,7 +99,10 @@ CONTAINER_ACTIONS: tuple[Action, ...] = (
),
Action(
name="container_exec",
method="LOCAL", path="", handler="container", requires_admin=True,
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Run a one-shot command inside a running instance and return its output",
params=(
SLUG,
@@ -67,13 +112,20 @@ CONTAINER_ACTIONS: tuple[Action, ...] = (
),
Action(
name="container_stats",
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
method="LOCAL",
path="",
handler="container",
requires_admin=True,
read_only=True,
summary="Get aggregated resource and runtime statistics for an instance",
params=(SLUG, arg("instance", "Instance name, slug, or uid.", required=True)),
),
Action(
name="container_schedule",
method="LOCAL", path="", handler="container", requires_admin=True,
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Schedule a start or stop of an instance (cron, interval, or one-time)",
params=(
SLUG,
@@ -5,8 +5,16 @@ from __future__ import annotations
from .spec import Action, Param
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
return Param(name=name, location="body", description=description, required=required, type=kind)
def arg(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
SCOPE = (
@@ -58,7 +66,12 @@ CUSTOMIZATION_ACTIONS: tuple[Action, ...] = (
params=(
arg("scope", SCOPE, required=True),
arg("css", "The full CSS source to save for this scope.", required=True),
arg("confirm", "Must be true after the user has confirmed the scope.", required=True, kind="boolean"),
arg(
"confirm",
"Must be true after the user has confirmed the scope.",
required=True,
kind="boolean",
),
),
),
Action(
@@ -76,8 +89,17 @@ CUSTOMIZATION_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
params=(
arg("scope", SCOPE, required=True),
arg("js", "The full JavaScript source to save for this scope.", required=True),
arg("confirm", "Must be true after the user has confirmed the scope.", required=True, kind="boolean"),
arg(
"js",
"The full JavaScript source to save for this scope.",
required=True,
),
arg(
"confirm",
"Must be true after the user has confirmed the scope.",
required=True,
kind="boolean",
),
),
),
Action(
@@ -92,9 +114,21 @@ CUSTOMIZATION_ACTIONS: tuple[Action, ...] = (
handler="customization",
requires_auth=False,
params=(
arg("scope", SCOPE + " Use 'all' to remove every customization.", required=True),
arg("lang", "Optional: limit the reset to 'css' or 'js'. Omit to remove both."),
arg("confirm", "Must be true after the user has confirmed the deletion.", required=True, kind="boolean"),
arg(
"scope",
SCOPE + " Use 'all' to remove every customization.",
required=True,
),
arg(
"lang",
"Optional: limit the reset to 'css' or 'js'. Omit to remove both.",
),
arg(
"confirm",
"Must be true after the user has confirmed the deletion.",
required=True,
kind="boolean",
),
),
),
)
+49 -11
View File
@@ -30,13 +30,24 @@ from .spec import Action, Catalog
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
CONFIRM_REQUIRED = {"project_set_readonly", "project_set_private", "customize_set_css", "customize_set_js", "customize_reset"}
CONFIRM_REQUIRED = {
"project_set_readonly",
"project_set_private",
"customize_set_css",
"customize_set_js",
"customize_reset",
}
logger = logging.getLogger("devii.dispatch")
def _is_confirmed(arguments: dict[str, Any]) -> bool:
return str(arguments.get("confirm", "")).strip().lower() in ("true", "1", "yes", "on")
return str(arguments.get("confirm", "")).strip().lower() in (
"true",
"1",
"yes",
"on",
)
def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError | None:
@@ -55,7 +66,12 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
"confirm=true."
)
if name == "project_set_private":
making_private = str(arguments.get("value", "")).strip().lower() in ("true", "1", "yes", "on")
making_private = str(arguments.get("value", "")).strip().lower() in (
"true",
"1",
"yes",
"on",
)
change = (
"hide the project from everyone except its owner and administrators"
if making_private
@@ -102,15 +118,21 @@ class Dispatcher:
self._chunks = ChunkController(settings)
self._rsearch = RsearchController(settings)
from ..container import ContainerController
self._container = ContainerController(client)
from ..customization import CustomizationController
self._customization = CustomizationController(owner_kind, owner_id)
self._virtual_tools = virtual_tools
self._read_files: set[tuple[str, str]] = set()
@staticmethod
def _file_key(arguments: dict[str, Any]) -> tuple[str, str] | None:
from devplacepy.project_files import normalize_path, ProjectFileError as _PFError
from devplacepy.project_files import (
normalize_path,
ProjectFileError as _PFError,
)
raw = arguments.get("path")
if not raw:
return None
@@ -162,7 +184,9 @@ class Dispatcher:
result = await self._run(action, arguments)
if action.handler == "chunks":
return result
return wrap_if_large(result, self._settings.max_response_chars, resource_key)
return wrap_if_large(
result, self._settings.max_response_chars, resource_key
)
except DeviiError as exc:
logger.info("Dispatch %s failed: %s", name, exc.message)
return error_result(exc)
@@ -184,7 +208,8 @@ class Dispatcher:
result = await self._client.login(
email=self._require(arguments, "email"),
password=self._require(arguments, "password"),
remember_me=str(arguments.get("remember_me", "on")).lower() not in ("", "false", "off", "no"),
remember_me=str(arguments.get("remember_me", "on")).lower()
not in ("", "false", "off", "no"),
)
return json.dumps(result, ensure_ascii=False)
@@ -221,7 +246,9 @@ class Dispatcher:
if action.handler == "virtual_tool":
if self._virtual_tools is None:
return error_result(
ToolInputError("User-defined tools are not available in this context.")
ToolInputError(
"User-defined tools are not available in this context."
)
)
return await self._virtual_tools.dispatch(action.name, arguments)
@@ -257,11 +284,14 @@ class Dispatcher:
if param.name not in arguments or arguments[param.name] is None:
if param.required:
raise ToolInputError(
f"Missing required parameter '{param.name}' for {action.name}.")
f"Missing required parameter '{param.name}' for {action.name}."
)
continue
value = arguments[param.name]
if param.location == "path":
url_path = url_path.replace("{" + param.name + "}", quote(str(value), safe=""))
url_path = url_path.replace(
"{" + param.name + "}", quote(str(value), safe="")
)
elif param.location == "query":
params[param.name] = value
elif param.location == "body":
@@ -309,7 +339,11 @@ class Dispatcher:
async def _run_http(self, action: Action, arguments: dict[str, Any]) -> str:
if action.name == "project_write_file":
key = self._file_key(arguments)
if key is not None and key not in self._read_files and await self._file_exists(arguments):
if (
key is not None
and key not in self._read_files
and await self._file_exists(arguments)
):
raise ToolInputError(
f"Read '{key[1]}' before overwriting it. It already exists; call "
"project_read_file first. For an existing file prefer the line tools "
@@ -328,7 +362,11 @@ class Dispatcher:
file_field=file_field,
headers=headers,
)
if action.name in ("project_read_file", "project_read_lines", "project_write_file"):
if action.name in (
"project_read_file",
"project_read_lines",
"project_write_file",
):
key = self._file_key(arguments)
if key is not None:
self._read_files.add(key)
@@ -25,11 +25,35 @@ RSEARCH_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
read_only=True,
params=(
Param(name="query", location="body", description="The web search query.", required=True),
Param(name="count", location="body", description="Number of results (1-100, default 10).", type="integer"),
Param(name="content", location="body", description="Fetch full page content for each result.", type="boolean"),
Param(name="deep", location="body", description="Run a deeper research pass.", type="boolean"),
Param(name="type", location="body", description="Result type: 'web' (default) or 'images'."),
Param(
name="query",
location="body",
description="The web search query.",
required=True,
),
Param(
name="count",
location="body",
description="Number of results (1-100, default 10).",
type="integer",
),
Param(
name="content",
location="body",
description="Fetch full page content for each result.",
type="boolean",
),
Param(
name="deep",
location="body",
description="Run a deeper research pass.",
type="boolean",
),
Param(
name="type",
location="body",
description="Result type: 'web' (default) or 'images'.",
),
),
),
Action(
@@ -46,9 +70,24 @@ RSEARCH_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
read_only=True,
params=(
Param(name="query", location="body", description="The question or prompt to answer.", required=True),
Param(name="content", location="body", description="Let the AI read full page content while answering.", type="boolean"),
Param(name="count", location="body", description="Max sources to consider (1-100, default 10).", type="integer"),
Param(
name="query",
location="body",
description="The question or prompt to answer.",
required=True,
),
Param(
name="content",
location="body",
description="Let the AI read full page content while answering.",
type="boolean",
),
Param(
name="count",
location="body",
description="Max sources to consider (1-100, default 10).",
type="integer",
),
),
),
Action(
@@ -65,9 +104,23 @@ RSEARCH_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
read_only=True,
params=(
Param(name="prompt", location="body", description="The prompt to send.", required=True),
Param(name="json", location="body", description="Force a valid-JSON-only response.", type="boolean"),
Param(name="system", location="body", description="Optional system message to steer the answer."),
Param(
name="prompt",
location="body",
description="The prompt to send.",
required=True,
),
Param(
name="json",
location="body",
description="Force a valid-JSON-only response.",
type="boolean",
),
Param(
name="system",
location="body",
description="Optional system message to steer the answer.",
),
),
),
Action(
@@ -83,7 +136,12 @@ RSEARCH_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
read_only=True,
params=(
Param(name="url", location="body", description="Public URL of the image to describe.", required=True),
Param(
name="url",
location="body",
description="Public URL of the image to describe.",
required=True,
),
),
),
)
+22 -4
View File
@@ -28,8 +28,20 @@ class Action:
requires_auth: bool = True
requires_admin: bool = False
handler: Literal[
"http", "login", "logout", "status", "task", "agentic", "avatar", "client", "fetch",
"docs", "cost", "chunks", "rsearch", "container"
"http",
"login",
"logout",
"status",
"task",
"agentic",
"avatar",
"client",
"fetch",
"docs",
"cost",
"chunks",
"rsearch",
"container",
] = "http"
freeform_body: bool = False
ajax: bool = False
@@ -62,7 +74,11 @@ class Action:
"description": "Additional form fields as key/value string pairs.",
"additionalProperties": {"type": "string"},
}
text = self.summary if not self.description else f"{self.summary}. {self.description}"
text = (
self.summary
if not self.description
else f"{self.summary}. {self.description}"
)
return {
"type": "function",
"function": {
@@ -88,7 +104,9 @@ class Catalog:
def tool_schemas(self) -> list[dict[str, Any]]:
return [action.tool_schema() for action in self.actions]
def tool_schemas_for(self, authenticated: bool, is_admin: bool = False) -> list[dict[str, Any]]:
def tool_schemas_for(
self, authenticated: bool, is_admin: bool = False
) -> list[dict[str, Any]]:
return [
action.tool_schema()
for action in self.actions
+3 -1
View File
@@ -198,7 +198,9 @@ class Agent:
self._on_trace = on_trace
self._cost_tracker = cost_tracker
self._chunk_store = chunk_store
self._messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
self._messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt}
]
async def respond(self, user_text: str) -> str:
self._inject_recalled_lessons(user_text)
@@ -6,4 +6,10 @@ from .lessons import LessonStore
from .loop import react_loop
from .state import AgentState
__all__ = ["AGENTIC_ACTIONS", "AgenticController", "LessonStore", "react_loop", "AgentState"]
__all__ = [
"AGENTIC_ACTIONS",
"AgenticController",
"LessonStore",
"react_loop",
"AgentState",
]
+70 -14
View File
@@ -5,8 +5,16 @@ from __future__ import annotations
from ..actions.spec import Action, Param
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
return Param(name=name, location="body", description=description, required=required, type=kind)
def arg(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
AGENTIC_ACTIONS: tuple[Action, ...] = (
@@ -20,9 +28,22 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
params=(
arg("goal", "One-line restatement of the user's goal.", required=True),
arg("steps", "Ordered list of step objects, each with id, action, depends_on.", required=True, kind="array"),
arg("success_criteria", "Concrete criteria for declaring the task complete.", required=True),
arg("confidence", "Self-estimate of plan correctness from 0.0 to 1.0.", kind="number"),
arg(
"steps",
"Ordered list of step objects, each with id, action, depends_on.",
required=True,
kind="array",
),
arg(
"success_criteria",
"Concrete criteria for declaring the task complete.",
required=True,
),
arg(
"confidence",
"Self-estimate of plan correctness from 0.0 to 1.0.",
kind="number",
),
),
),
Action(
@@ -34,9 +55,21 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
handler="agentic",
requires_auth=False,
params=(
arg("observation", "What was observed: the failure mode or notable outcome.", required=True),
arg("conclusion", "Diagnosis or interpretation of the observation.", required=True),
arg("next_action", "The chosen next step or the rule to apply in future.", required=True),
arg(
"observation",
"What was observed: the failure mode or notable outcome.",
required=True,
),
arg(
"conclusion",
"Diagnosis or interpretation of the observation.",
required=True,
),
arg(
"next_action",
"The chosen next step or the rule to apply in future.",
required=True,
),
arg("tags", "Optional comma-separated keywords to aid later recall."),
),
),
@@ -49,7 +82,11 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
handler="agentic",
requires_auth=False,
params=(
arg("query", "Natural-language or keyword query describing the current situation.", required=True),
arg(
"query",
"Natural-language or keyword query describing the current situation.",
required=True,
),
arg("k", "Number of lessons to return.", kind="integer"),
),
),
@@ -66,7 +103,10 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
handler="agentic",
requires_auth=False,
params=(
arg("query", "Optional topic/keywords; only matching lessons are forgotten. Omit to forget everything."),
arg(
"query",
"Optional topic/keywords; only matching lessons are forgotten. Omit to forget everything.",
),
),
),
Action(
@@ -82,7 +122,11 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
params=(
arg("summary", "What was confirmed and how.", required=True),
arg("confirmed", "Whether the change was confirmed successful.", kind="boolean"),
arg(
"confirmed",
"Whether the change was confirmed successful.",
kind="boolean",
),
),
),
Action(
@@ -94,8 +138,16 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
handler="agentic",
requires_auth=False,
params=(
arg("task", "Clear, scoped task description for the sub-agent.", required=True),
arg("allowed_tools", "Optional list of tool names the sub-agent may use.", kind="array"),
arg(
"task",
"Clear, scoped task description for the sub-agent.",
required=True,
),
arg(
"allowed_tools",
"Optional list of tool names the sub-agent may use.",
kind="array",
),
),
),
Action(
@@ -111,7 +163,11 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
handler="agentic",
requires_auth=False,
params=(
arg("prompt", "The instruction to run as a fresh sub-agent request.", required=True),
arg(
"prompt",
"The instruction to run as a fresh sub-agent request.",
required=True,
),
),
),
)
@@ -32,7 +32,9 @@ def find_compaction_split(messages: list[dict[str, Any]], keep_tail: int) -> int
return 1
async def compact_messages(llm: Any, messages: list[dict[str, Any]], keep_tail: int) -> list[dict[str, Any]]:
async def compact_messages(
llm: Any, messages: list[dict[str, Any]], keep_tail: int
) -> list[dict[str, Any]]:
if len(messages) < keep_tail + 3:
return messages
split = find_compaction_split(messages, keep_tail)
+44 -12
View File
@@ -92,7 +92,12 @@ class AgenticController:
if confidence < 0.6:
advice = "Confidence is below 0.6 - gather more context or recall() past lessons before executing."
return json.dumps(
{"status": "success", "plan_recorded": True, "step_count": len(steps), "advice": advice},
{
"status": "success",
"plan_recorded": True,
"step_count": len(steps),
"advice": advice,
},
ensure_ascii=False,
)
@@ -101,13 +106,19 @@ class AgenticController:
conclusion = str(arguments.get("conclusion", "")).strip()
next_action = str(arguments.get("next_action", "")).strip()
if not (observation and conclusion and next_action):
raise ToolInputError("reflect requires observation, conclusion, and next_action.")
raise ToolInputError(
"reflect requires observation, conclusion, and next_action."
)
tags = str(arguments.get("tags", "") or "").strip()
record = self._lessons.add(observation, conclusion, next_action, tags)
state = get_state()
if state is not None:
state.reflections.append(
{"observation": observation, "conclusion": conclusion, "next_action": next_action}
{
"observation": observation,
"conclusion": conclusion,
"next_action": next_action,
}
)
return json.dumps(
{
@@ -122,9 +133,15 @@ class AgenticController:
query = str(arguments.get("query", "")).strip()
if not query:
raise ToolInputError("recall requires a query.")
k = int(arguments.get("k", self._settings.recall_top_k) or self._settings.recall_top_k)
k = int(
arguments.get("k", self._settings.recall_top_k)
or self._settings.recall_top_k
)
hits = self._lessons.search(query, k=k)
return json.dumps({"status": "success", "count": len(hits), "lessons": hits}, ensure_ascii=False)
return json.dumps(
{"status": "success", "count": len(hits), "lessons": hits},
ensure_ascii=False,
)
async def _forget(self, arguments: dict[str, Any]) -> str:
query = str(arguments.get("query", "") or "").strip()
@@ -137,7 +154,11 @@ class AgenticController:
else:
removed = self._lessons.clear()
return json.dumps(
{"status": "success", "forgotten": removed, "remaining": self._lessons.count()},
{
"status": "success",
"forgotten": removed,
"remaining": self._lessons.count(),
},
ensure_ascii=False,
)
@@ -152,14 +173,20 @@ class AgenticController:
if state is not None and confirmed:
state.verified = True
return json.dumps(
{"status": "success", "verified": bool(confirmed), "summary": summary}, ensure_ascii=False
{"status": "success", "verified": bool(confirmed), "summary": summary},
ensure_ascii=False,
)
async def _spawn(
self, prompt: str, tools: list[dict[str, Any]], system_prompt: str = SUB_AGENT_SYSTEM_PROMPT
self,
prompt: str,
tools: list[dict[str, Any]],
system_prompt: str = SUB_AGENT_SYSTEM_PROMPT,
) -> tuple[str, AgentState]:
if self._llm is None or self._dispatcher is None:
raise ToolInputError("Sub-agent execution is not available in this context.")
raise ToolInputError(
"Sub-agent execution is not available in this context."
)
depth = get_eval_depth()
if depth >= MAX_EVAL_DEPTH:
raise ToolInputError(
@@ -194,7 +221,9 @@ class AgenticController:
prompt = str(prompt or "").strip()
if not prompt:
raise ToolInputError("A non-empty prompt is required.")
tools = [tool for tool in self._tools if tool["function"]["name"] != NO_DELEGATE]
tools = [
tool for tool in self._tools if tool["function"]["name"] != NO_DELEGATE
]
result, _ = await self._spawn(prompt, tools)
return result
@@ -208,10 +237,13 @@ class AgenticController:
tools = [
tool
for tool in self._tools
if tool["function"]["name"] in allowed_set and tool["function"]["name"] != NO_DELEGATE
if tool["function"]["name"] in allowed_set
and tool["function"]["name"] != NO_DELEGATE
]
else:
tools = [tool for tool in self._tools if tool["function"]["name"] != NO_DELEGATE]
tools = [
tool for tool in self._tools if tool["function"]["name"] != NO_DELEGATE
]
result, sub_state = await self._spawn(task, tools)
return json.dumps(
{
+9 -3
View File
@@ -59,7 +59,9 @@ class LessonStore:
return 0
return self._table.count(**self._scope)
def add(self, observation: str, conclusion: str, next_action: str, tags: str = "") -> dict[str, Any]:
def add(
self, observation: str, conclusion: str, next_action: str, tags: str = ""
) -> dict[str, Any]:
record = {
"uid": uuid.uuid4().hex,
"observation": observation,
@@ -72,7 +74,9 @@ class LessonStore:
}
self._table.insert(record)
self._dirty = True
logger.info("Lesson stored owner=%s/%s tags=%s", self._owner_kind, self._owner_id, tags)
logger.info(
"Lesson stored owner=%s/%s tags=%s", self._owner_kind, self._owner_id, tags
)
return record
def all(self) -> list[dict[str, Any]]:
@@ -92,7 +96,9 @@ class LessonStore:
if TABLE in self._db.tables:
self._table.delete(**self._scope)
self._dirty = True
logger.info("Cleared %d lesson(s) for owner=%s/%s", n, self._owner_kind, self._owner_id)
logger.info(
"Cleared %d lesson(s) for owner=%s/%s", n, self._owner_kind, self._owner_id
)
return n
def _rebuild(self) -> None:
+54 -21
View File
@@ -104,7 +104,10 @@ def normalize(message: dict[str, Any]) -> dict[str, Any]:
def _is_error(result: str) -> bool:
try:
return isinstance(json.loads(result), dict) and json.loads(result).get("error") is not None
return (
isinstance(json.loads(result), dict)
and json.loads(result).get("error") is not None
)
except (ValueError, TypeError):
return False
@@ -128,19 +131,30 @@ async def _run_tool_call(dispatcher: Any, call: dict[str, Any]) -> str:
name = function.get("name", "")
raw_arguments = function.get("arguments") or "{}"
try:
arguments = json.loads(raw_arguments) if isinstance(raw_arguments, str) else raw_arguments
arguments = (
json.loads(raw_arguments)
if isinstance(raw_arguments, str)
else raw_arguments
)
except json.JSONDecodeError as exc:
return json.dumps({
"error": "tool_input_truncated",
"message": (
f"The arguments for {name or 'this tool'} were cut off and could not be parsed "
f"({exc.msg} at position {exc.pos}); the model output hit its length limit. "
"Emit only one write tool call per turn (do not batch several file writes into a "
"single response) and resend this one call on its own."
),
})
return json.dumps(
{
"error": "tool_input_truncated",
"message": (
f"The arguments for {name or 'this tool'} were cut off and could not be parsed "
f"({exc.msg} at position {exc.pos}); the model output hit its length limit. "
"Emit only one write tool call per turn (do not batch several file writes into a "
"single response) and resend this one call on its own."
),
}
)
if not isinstance(arguments, dict):
return json.dumps({"error": "tool_input_error", "message": "Invalid arguments: arguments must be an object"})
return json.dumps(
{
"error": "tool_input_error",
"message": "Invalid arguments: arguments must be an object",
}
)
return await dispatcher.dispatch(name, arguments)
@@ -172,7 +186,9 @@ async def react_loop(
if context_size(messages) > settings.context_compact_threshold:
trace("compact")
messages[:] = await compact_messages(llm, messages, settings.context_keep_tail)
messages[:] = await compact_messages(
llm, messages, settings.context_keep_tail
)
try:
message = await llm.complete(messages, tools)
@@ -184,10 +200,14 @@ async def react_loop(
tool_calls = message.get("tool_calls") or []
if tool_calls:
needs_plan = plan_required and state.plan is None and any(
call["function"]["name"] != "plan"
and not dispatcher.is_read_only(call["function"]["name"])
for call in tool_calls
needs_plan = (
plan_required
and state.plan is None
and any(
call["function"]["name"] != "plan"
and not dispatcher.is_read_only(call["function"]["name"])
for call in tool_calls
)
)
if needs_plan:
trace("plan-gate")
@@ -197,13 +217,19 @@ async def react_loop(
"role": "tool",
"tool_call_id": call.get("id", ""),
"name": call.get("function", {}).get("name", ""),
"content": json.dumps({"error": "protocol", "message": PLAN_VIOLATION}),
"content": json.dumps(
{"error": "protocol", "message": PLAN_VIOLATION}
),
}
)
continue
for call in tool_calls:
trace("call", call.get("function", {}).get("name", ""), call_label(call))
trace(
"call",
call.get("function", {}).get("name", ""),
call_label(call),
)
results = await asyncio.gather(
*[_run_tool_call(dispatcher, call) for call in tool_calls]
@@ -212,7 +238,10 @@ async def react_loop(
any_error = False
for call, result in zip(tool_calls, results):
if len(result) > OUTPUT_CAP_CHARS:
result = result[:OUTPUT_CAP_CHARS] + f"\n...[truncated {len(result)} chars]"
result = (
result[:OUTPUT_CAP_CHARS]
+ f"\n...[truncated {len(result)} chars]"
)
name = call.get("function", {}).get("name", "")
messages.append(
{
@@ -224,7 +253,11 @@ async def react_loop(
)
label = call_label(call)
summary = _summary(result)
detail = " ".join(part for part in (label, f"({summary})" if summary else "") if part)
detail = " ".join(
part
for part in (label, f"({summary})" if summary else "")
if part
)
if _is_error(result):
any_error = True
trace("err", name, detail)
+6 -2
View File
@@ -6,8 +6,12 @@ import contextvars
from dataclasses import dataclass, field
from typing import Any, Optional
_active_state: contextvars.ContextVar = contextvars.ContextVar("devii_agent_state", default=None)
_eval_depth: contextvars.ContextVar = contextvars.ContextVar("devii_eval_depth", default=0)
_active_state: contextvars.ContextVar = contextvars.ContextVar(
"devii_agent_state", default=None
)
_eval_depth: contextvars.ContextVar = contextvars.ContextVar(
"devii_eval_depth", default=0
)
MAX_EVAL_DEPTH = 2
@@ -37,14 +37,16 @@ class AvatarController:
return self._request is not None
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
action = name[len(PREFIX):] if name.startswith(PREFIX) else name
action = name[len(PREFIX) :] if name.startswith(PREFIX) else name
if self._request is None:
return json.dumps(UNAVAILABLE, ensure_ascii=False)
try:
result = await self._request(action, arguments)
except Exception as exc: # noqa: BLE001 - surfaced to the model as data
logger.exception("Avatar action %s failed", action)
return json.dumps({"status": "error", "action": action, "message": str(exc)})
return json.dumps(
{"status": "error", "action": action, "message": str(exc)}
)
if isinstance(result, dict) and result.get("error"):
return json.dumps(
@@ -52,5 +54,6 @@ class AvatarController:
ensure_ascii=False,
)
return json.dumps(
{"status": "success", "action": action, "result": result}, ensure_ascii=False
{"status": "success", "action": action, "result": result},
ensure_ascii=False,
)
@@ -20,7 +20,10 @@ class ChunkController:
store = get_store()
if store is None:
return json.dumps(
{"status": "unavailable", "message": "No chunk store is active for this session."}
{
"status": "unavailable",
"message": "No chunk store is active for this session.",
}
)
chunk_id = str(arguments.get("chunk_id", "")).strip()
if not chunk_id:
@@ -38,7 +41,10 @@ class ChunkController:
)
offset = max(0, int(arguments.get("offset", 0) or 0))
length = int(arguments.get("length", self._settings.max_response_chars) or self._settings.max_response_chars)
length = int(
arguments.get("length", self._settings.max_response_chars)
or self._settings.max_response_chars
)
length = max(1, min(length, self._settings.max_response_chars))
total = len(text)
+6 -2
View File
@@ -14,7 +14,9 @@ logger = logging.getLogger("devii.chunks")
STORE_MAX_ENTRIES = 16
STORE_MAX_CHARS = 8_000_000
_active_store: contextvars.ContextVar = contextvars.ContextVar("devii_chunk_store", default=None)
_active_store: contextvars.ContextVar = contextvars.ContextVar(
"devii_chunk_store", default=None
)
class ChunkStore:
@@ -97,7 +99,9 @@ def chunk_envelope(text: str, chunk_id: str, max_chars: int) -> str:
)
def wrap_if_large(result: str, max_chars: int, resource_key: Optional[str] = None) -> str:
def wrap_if_large(
result: str, max_chars: int, resource_key: Optional[str] = None
) -> str:
if len(result) <= max_chars:
return result
store = get_store()
+54 -15
View File
@@ -87,8 +87,13 @@ def _make_executor(
async def execute(prompt: str) -> str:
worker = Agent(
settings, llm, dispatcher, tools,
lessons=lessons, cost_tracker=cost_tracker, chunk_store=chunk_store,
settings,
llm,
dispatcher,
tools,
lessons=lessons,
cost_tracker=cost_tracker,
chunk_store=chunk_store,
)
return await worker.respond(prompt)
@@ -113,6 +118,7 @@ def _resolve_owner(settings: Settings) -> tuple[str, str]:
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")
@@ -132,12 +138,18 @@ 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)
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)
client = PlatformClient(
settings.base_url, settings.timeout_seconds, settings.platform_api_key
)
llm = LLMClient(settings)
greeting = await _bootstrap_auth(client, settings)
@@ -147,15 +159,27 @@ async def run(settings: Settings, prompt: Optional[str] = None) -> None:
cost_tracker = CostTracker()
chunk_store = ChunkStore()
agentic = AgenticController(lessons, settings)
dispatcher = Dispatcher(CATALOG, client, settings, controller, agentic, is_admin=True)
dispatcher = Dispatcher(
CATALOG, client, settings, controller, agentic, is_admin=True
)
tools = CATALOG.tool_schemas_for(client.authenticated, is_admin=True)
agentic.bind(
llm=llm, dispatcher=dispatcher, tools=tools,
on_trace=_trace, cost_tracker=cost_tracker, chunk_store=chunk_store,
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,
settings,
llm,
dispatcher,
tools,
lessons=lessons,
on_trace=_trace,
cost_tracker=cost_tracker,
chunk_store=chunk_store,
)
if prompt is not None:
@@ -216,11 +240,19 @@ def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
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(
"-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(
"--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.")
@@ -248,7 +280,14 @@ def _settings_from_args(args: argparse.Namespace) -> Settings:
return replace(settings, **overrides)
NOISY_LOGGERS = ("httpcore", "httpx", "httpcore.http11", "httpcore.connection", "asyncio", "urllib3")
NOISY_LOGGERS = (
"httpcore",
"httpx",
"httpcore.http11",
"httpcore.connection",
"asyncio",
"urllib3",
)
def main(argv: Optional[list[str]] = None) -> None:
@@ -49,10 +49,16 @@ class ClientController:
result = await self._request(name, arguments)
except Exception as exc: # noqa: BLE001 - surfaced to the model as data
logger.exception("Client action %s failed", name)
return json.dumps({"status": "error", "action": name, "message": str(exc)}, ensure_ascii=False)
return json.dumps(
{"status": "error", "action": name, "message": str(exc)},
ensure_ascii=False,
)
if isinstance(result, dict) and result.get("error"):
return json.dumps(
{"status": "error", "action": name, "message": str(result["error"])}, ensure_ascii=False
{"status": "error", "action": name, "message": str(result["error"])},
ensure_ascii=False,
)
return json.dumps({"status": "success", "action": name, "result": result}, ensure_ascii=False)
return json.dumps(
{"status": "success", "action": name, "result": result}, ensure_ascii=False
)
+50 -17
View File
@@ -16,7 +16,9 @@ CONTEXT_WINDOW_TOKENS = 1_048_576
MAX_OUTPUT_TOKENS = 384_000
SYSTEM_RESERVE_TOKENS = 64_000
CHARS_PER_TOKEN = 3
CONTEXT_INPUT_BUDGET_TOKENS = CONTEXT_WINDOW_TOKENS - MAX_OUTPUT_TOKENS - SYSTEM_RESERVE_TOKENS
CONTEXT_INPUT_BUDGET_TOKENS = (
CONTEXT_WINDOW_TOKENS - MAX_OUTPUT_TOKENS - SYSTEM_RESERVE_TOKENS
)
MIN_TIMEOUT_SECONDS = 300.0
DEFAULT_TIMEOUT_SECONDS = 300.0
@@ -73,6 +75,7 @@ def _default_ai_key() -> str:
return env_key
try:
from devplacepy.database import internal_gateway_key
key = internal_gateway_key()
if key:
return key
@@ -88,36 +91,56 @@ def load_settings() -> Settings:
ai_model=os.environ.get("DEVII_AI_MODEL", DEFAULT_AI_MODEL),
base_url=os.environ.get("DEVII_BASE_URL", DEFAULT_BASE_URL).rstrip("/"),
timeout_seconds=float(os.environ.get("DEVII_TIMEOUT", DEFAULT_TIMEOUT_SECONDS)),
max_response_chars=int(os.environ.get("DEVII_MAX_RESPONSE", DEFAULT_MAX_RESPONSE_CHARS)),
max_tool_iterations=int(os.environ.get("DEVII_MAX_TOOL_ITERATIONS", DEFAULT_MAX_TOOL_ITERATIONS)),
max_response_chars=int(
os.environ.get("DEVII_MAX_RESPONSE", DEFAULT_MAX_RESPONSE_CHARS)
),
max_tool_iterations=int(
os.environ.get("DEVII_MAX_TOOL_ITERATIONS", DEFAULT_MAX_TOOL_ITERATIONS)
),
log_level=os.environ.get("DEVII_LOG_LEVEL", "WARNING").upper(),
log_path=os.environ.get("DEVII_LOG_PATH", "devii.log"),
tasks_db_path=os.environ.get("DEVII_TASKS_DB", "devii_tasks.db"),
scheduler_tick_seconds=float(os.environ.get("DEVII_SCHEDULER_TICK", "1.0")),
lessons_db_path=os.environ.get("DEVII_LESSONS_DB", "devii_lessons.db"),
delegate_max_iterations=int(
os.environ.get("DEVII_DELEGATE_MAX_ITERATIONS", DEFAULT_DELEGATE_MAX_ITERATIONS)
os.environ.get(
"DEVII_DELEGATE_MAX_ITERATIONS", DEFAULT_DELEGATE_MAX_ITERATIONS
)
),
context_compact_threshold=int(
os.environ.get("DEVII_CONTEXT_COMPACT_THRESHOLD", DEFAULT_CONTEXT_COMPACT_THRESHOLD)
os.environ.get(
"DEVII_CONTEXT_COMPACT_THRESHOLD", DEFAULT_CONTEXT_COMPACT_THRESHOLD
)
),
context_keep_tail=int(
os.environ.get("DEVII_CONTEXT_KEEP_TAIL", DEFAULT_CONTEXT_KEEP_TAIL)
),
context_keep_tail=int(os.environ.get("DEVII_CONTEXT_KEEP_TAIL", DEFAULT_CONTEXT_KEEP_TAIL)),
recall_top_k=int(os.environ.get("DEVII_RECALL_TOP_K", DEFAULT_RECALL_TOP_K)),
plan_required=os.environ.get("DEVII_PLAN_REQUIRED", "1").lower() not in ("0", "false", "no"),
verify_required=os.environ.get("DEVII_VERIFY_REQUIRED", "1").lower() not in ("0", "false", "no"),
plan_required=os.environ.get("DEVII_PLAN_REQUIRED", "1").lower()
not in ("0", "false", "no"),
verify_required=os.environ.get("DEVII_VERIFY_REQUIRED", "1").lower()
not in ("0", "false", "no"),
platform_api_key=os.environ.get("DEVII_PLATFORM_API_KEY", ""),
login_email=os.environ.get("DEVII_LOGIN_EMAIL", ""),
login_password=os.environ.get("DEVII_LOGIN_PASSWORD", ""),
fetch_max_chars=int(os.environ.get("DEVII_FETCH_MAX_CHARS", DEFAULT_FETCH_MAX_CHARS)),
fetch_max_chars=int(
os.environ.get("DEVII_FETCH_MAX_CHARS", DEFAULT_FETCH_MAX_CHARS)
),
fetch_timeout_seconds=float(
os.environ.get("DEVII_FETCH_TIMEOUT", DEFAULT_FETCH_TIMEOUT_SECONDS)
),
fetch_max_bytes=int(os.environ.get("DEVII_FETCH_MAX_BYTES", DEFAULT_FETCH_MAX_BYTES)),
fetch_max_bytes=int(
os.environ.get("DEVII_FETCH_MAX_BYTES", DEFAULT_FETCH_MAX_BYTES)
),
fetch_allow_private=os.environ.get("DEVII_FETCH_ALLOW_PRIVATE", "0").lower()
in ("1", "true", "yes", "on"),
allow_eval=os.environ.get("DEVII_ALLOW_EVAL", "1").lower() in ("1", "true", "yes", "on"),
rsearch_enabled=os.environ.get("DEVII_RSEARCH_ENABLED", "1").lower() in ("1", "true", "yes", "on"),
rsearch_url=os.environ.get("DEVII_RSEARCH_URL", DEFAULT_RSEARCH_URL).rstrip("/"),
allow_eval=os.environ.get("DEVII_ALLOW_EVAL", "1").lower()
in ("1", "true", "yes", "on"),
rsearch_enabled=os.environ.get("DEVII_RSEARCH_ENABLED", "1").lower()
in ("1", "true", "yes", "on"),
rsearch_url=os.environ.get("DEVII_RSEARCH_URL", DEFAULT_RSEARCH_URL).rstrip(
"/"
),
rsearch_timeout_seconds=float(
os.environ.get("DEVII_RSEARCH_TIMEOUT", DEFAULT_RSEARCH_TIMEOUT_SECONDS)
),
@@ -148,7 +171,9 @@ FIELD_RSEARCH_TIMEOUT = "devii_rsearch_timeout"
LESSONS_DB_PATH = os.environ.get("DEVII_LESSONS_DB", "devii_lessons.db")
def effective_daily_limit(config: dict, owner_kind: str, is_admin: bool = False) -> float:
def effective_daily_limit(
config: dict, owner_kind: str, is_admin: bool = False
) -> float:
if is_admin:
return float(config.get(FIELD_ADMIN_DAILY_USD, 0.0) or 0.0)
if owner_kind == "guest":
@@ -157,7 +182,11 @@ def effective_daily_limit(config: dict, owner_kind: str, is_admin: bool = False)
def build_settings(
config: dict, base_url: str, api_key: str, owner_kind: str = "guest", is_admin: bool = False
config: dict,
base_url: str,
api_key: str,
owner_kind: str = "guest",
is_admin: bool = False,
) -> Settings:
configured_key = config[FIELD_AI_KEY] or str(uuid.uuid4())
ai_key = api_key if (owner_kind == "user" and api_key) else configured_key
@@ -184,12 +213,16 @@ def build_settings(
login_email="",
login_password="",
fetch_max_chars=DEFAULT_FETCH_MAX_CHARS,
fetch_timeout_seconds=float(config.get(FIELD_FETCH_TIMEOUT) or DEFAULT_FETCH_TIMEOUT_SECONDS),
fetch_timeout_seconds=float(
config.get(FIELD_FETCH_TIMEOUT) or DEFAULT_FETCH_TIMEOUT_SECONDS
),
fetch_max_bytes=DEFAULT_FETCH_MAX_BYTES,
fetch_allow_private=False,
allow_eval=bool(config.get(FIELD_ALLOW_EVAL, True)),
rsearch_enabled=bool(config.get(FIELD_RSEARCH_ENABLED, True)),
rsearch_url=(config.get(FIELD_RSEARCH_URL) or DEFAULT_RSEARCH_URL).rstrip("/"),
rsearch_timeout_seconds=float(config.get(FIELD_RSEARCH_TIMEOUT) or DEFAULT_RSEARCH_TIMEOUT_SECONDS),
rsearch_timeout_seconds=float(
config.get(FIELD_RSEARCH_TIMEOUT) or DEFAULT_RSEARCH_TIMEOUT_SECONDS
),
daily_limit_usd=effective_daily_limit(config, owner_kind, is_admin),
)
@@ -23,6 +23,7 @@ class ContainerController:
if not slug:
return None
from devplacepy.seo import public_base_url
base = public_base_url()
return f"{base}/p/{slug}" if base else f"/p/{slug}"
@@ -51,7 +52,11 @@ class ContainerController:
return inst
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
handler = getattr(self, "_" + name[len("container_"):], None) if name.startswith("container_") else None
handler = (
getattr(self, "_" + name[len("container_") :], None)
if name.startswith("container_")
else None
)
if handler is None:
raise ToolInputError(f"unknown container tool: {name}")
try:
@@ -68,15 +73,29 @@ class ContainerController:
async def _create_instance(self, arguments) -> str:
project = self._project(arguments)
autostart = str(arguments.get("autostart", "true")).lower() not in ("false", "0", "no", "off")
autostart = str(arguments.get("autostart", "true")).lower() not in (
"false",
"0",
"no",
"off",
)
inst = await api.create_instance(
project, name=str(arguments.get("name", "")),
boot_command=str(arguments.get("boot_command", "")), env=arguments.get("env", ""),
cpu_limit=str(arguments.get("cpu_limit", "")), mem_limit=str(arguments.get("mem_limit", "")),
ports=arguments.get("ports", ""), restart_policy=str(arguments.get("restart_policy", "never")),
autostart=autostart, ingress_slug=str(arguments.get("ingress_slug", "")),
ingress_port=arguments.get("ingress_port"), actor=("user", self._actor_user()["uid"]))
return json.dumps({"instance": inst, "ingress_url": self._ingress_url(inst)}, default=str)
project,
name=str(arguments.get("name", "")),
boot_command=str(arguments.get("boot_command", "")),
env=arguments.get("env", ""),
cpu_limit=str(arguments.get("cpu_limit", "")),
mem_limit=str(arguments.get("mem_limit", "")),
ports=arguments.get("ports", ""),
restart_policy=str(arguments.get("restart_policy", "never")),
autostart=autostart,
ingress_slug=str(arguments.get("ingress_slug", "")),
ingress_port=arguments.get("ingress_port"),
actor=("user", self._actor_user()["uid"]),
)
return json.dumps(
{"instance": inst, "ingress_url": self._ingress_url(inst)}, default=str
)
async def _instance_action(self, arguments) -> str:
project = self._project(arguments)
@@ -111,7 +130,12 @@ class ContainerController:
lines.append(line)
tail = int(arguments.get("tail", 200) or 200)
await get_backend().logs(inst["container_id"], follow=False, tail=max(1, min(tail, 2000)), on_log=collect)
await get_backend().logs(
inst["container_id"],
follow=False,
tail=max(1, min(tail, 2000)),
on_log=collect,
)
return json.dumps({"logs": "\n".join(lines)})
async def _exec(self, arguments) -> str:
@@ -122,20 +146,32 @@ class ContainerController:
command = str(arguments.get("command", "")).strip()
if not command:
raise ToolInputError("command is required")
result = await get_backend().exec(inst["container_id"], ["/bin/sh", "-c", command])
result = await get_backend().exec(
inst["container_id"], ["/bin/sh", "-c", command]
)
actor = self._actor_user()
store.record_event(inst, "exec", "user", actor["uid"], {"command": command, "exit_code": result.exit_code})
store.record_event(
inst,
"exec",
"user",
actor["uid"],
{"command": command, "exit_code": result.exit_code},
)
return json.dumps({"exit_code": result.exit_code, "output": result.output})
async def _stats(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
return json.dumps({
"instance": inst["name"], "status": inst["status"],
"ingress_url": self._ingress_url(inst),
"runtime": api.instance_runtime(inst),
"stats": api.instance_stats(inst["uid"]),
}, default=str)
return json.dumps(
{
"instance": inst["name"],
"status": inst["status"],
"ingress_url": self._ingress_url(inst),
"runtime": api.instance_runtime(inst),
"stats": api.instance_stats(inst["uid"]),
},
default=str,
)
async def _schedule(self, arguments) -> str:
project = self._project(arguments)
@@ -143,9 +179,11 @@ class ContainerController:
run_at = arguments.get("run_at")
try:
schedule = Schedule(
kind=str(arguments.get("kind", "")), cron=arguments.get("cron") or None,
kind=str(arguments.get("kind", "")),
cron=arguments.get("cron") or None,
run_at=from_iso(run_at) if run_at else None,
every_seconds=arguments.get("every_seconds"))
every_seconds=arguments.get("every_seconds"),
)
except ValueError as exc:
raise ToolInputError(str(exc))
sched = api.add_schedule(inst, str(arguments.get("action", "")), schedule)
+8 -1
View File
@@ -3,4 +3,11 @@
from .controller import CostController
from .tracker import CostTracker, Pricing, record_usage, reset_tracker, set_tracker
__all__ = ["CostTracker", "Pricing", "CostController", "record_usage", "set_tracker", "reset_tracker"]
__all__ = [
"CostTracker",
"Pricing",
"CostController",
"record_usage",
"set_tracker",
"reset_tracker",
]
+17 -5
View File
@@ -10,21 +10,33 @@ from .tracker import get_tracker
class CostController:
def __init__(self, quota_provider: Optional[Callable[[], dict[str, Any]]] = None) -> None:
def __init__(
self, quota_provider: Optional[Callable[[], dict[str, Any]]] = None
) -> None:
self._quota_provider = quota_provider
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
if name == "usage_quota":
if self._quota_provider is None:
return json.dumps(
{"status": "unavailable", "message": "No quota information is available for this session."}
{
"status": "unavailable",
"message": "No quota information is available for this session.",
}
)
return json.dumps({"status": "success", **self._quota_provider()}, ensure_ascii=False)
return json.dumps(
{"status": "success", **self._quota_provider()}, ensure_ascii=False
)
if name == "cost_stats":
tracker = get_tracker()
if tracker is None:
return json.dumps(
{"status": "unavailable", "message": "No cost tracker is active for this session."}
{
"status": "unavailable",
"message": "No cost tracker is active for this session.",
}
)
return json.dumps({"status": "success", **tracker.stats()}, ensure_ascii=False)
return json.dumps(
{"status": "success", **tracker.stats()}, ensure_ascii=False
)
raise ToolInputError(f"Unknown cost tool: {name}")
+17 -5
View File
@@ -28,7 +28,9 @@ class Pricing:
DEFAULT_PRICING = Pricing()
_active_tracker: contextvars.ContextVar = contextvars.ContextVar("devii_cost_tracker", default=None)
_active_tracker: contextvars.ContextVar = contextvars.ContextVar(
"devii_cost_tracker", default=None
)
def _now() -> datetime:
@@ -76,12 +78,18 @@ class CostTracker:
self.cache_miss_tokens += miss
self.reasoning_tokens += reasoning
logger.debug(
"Cost record: +%d prompt (+%d hit/+%d miss), +%d completion", prompt, hit, miss, completion
"Cost record: +%d prompt (+%d hit/+%d miss), +%d completion",
prompt,
hit,
miss,
completion,
)
def cost_usd(self) -> dict[str, float]:
cache_hit = self.cache_hit_tokens / PER_MILLION * self.pricing.cache_hit_per_m
cache_miss = self.cache_miss_tokens / PER_MILLION * self.pricing.cache_miss_per_m
cache_miss = (
self.cache_miss_tokens / PER_MILLION * self.pricing.cache_miss_per_m
)
output = self.completion_tokens / PER_MILLION * self.pricing.output_per_m
total = cache_hit + cache_miss + output
return {
@@ -95,7 +103,9 @@ class CostTracker:
elapsed = max((_now() - self.started_at).total_seconds(), 0.0)
costs = self.cost_usd()
requests = self.requests or 1
cache_hit_rate = (self.cache_hit_tokens / self.prompt_tokens) if self.prompt_tokens else 0.0
cache_hit_rate = (
(self.cache_hit_tokens / self.prompt_tokens) if self.prompt_tokens else 0.0
)
per_minute = (self.requests / elapsed * 60.0) if elapsed > 0 else 0.0
return {
"model": self.pricing.model,
@@ -120,7 +130,9 @@ class CostTracker:
"averages": {
"tokens_per_request": round(self.total_tokens / requests, 2),
"prompt_tokens_per_request": round(self.prompt_tokens / requests, 2),
"completion_tokens_per_request": round(self.completion_tokens / requests, 2),
"completion_tokens_per_request": round(
self.completion_tokens / requests, 2
),
"cost_usd_per_request": round(costs["total"] / requests, 8),
},
"session": {
@@ -41,7 +41,9 @@ class CustomizationController:
def _scope(self, arguments: dict[str, Any]) -> str:
scope = str(arguments.get("scope", "")).strip()
if not scope:
raise ToolInputError("A 'scope' is required: 'global' or a page-type string.")
raise ToolInputError(
"A 'scope' is required: 'global' or a page-type string."
)
return scope
def _lang(self, arguments: dict[str, Any]) -> str | None:
@@ -66,7 +68,9 @@ class CustomizationController:
}
for row in rows
]
return json.dumps({"status": "success", "customizations": items}, ensure_ascii=False)
return json.dumps(
{"status": "success", "customizations": items}, ensure_ascii=False
)
def _get(self, arguments: dict[str, Any]) -> str:
scope = self._scope(arguments)
@@ -76,7 +80,8 @@ class CustomizationController:
row = get_custom_override(self._owner_kind, self._owner_id, scope, lang)
if not row:
return json.dumps(
{"status": "success", "scope": scope, "lang": lang, "code": None}, ensure_ascii=False
{"status": "success", "scope": scope, "lang": lang, "code": None},
ensure_ascii=False,
)
return json.dumps(
{
@@ -110,9 +115,13 @@ class CustomizationController:
scope = self._scope(arguments)
lang = self._lang(arguments)
if scope == "all":
removed = delete_custom_override(self._owner_kind, self._owner_id, scope=None, lang=lang)
removed = delete_custom_override(
self._owner_kind, self._owner_id, scope=None, lang=lang
)
else:
removed = delete_custom_override(self._owner_kind, self._owner_id, scope=scope, lang=lang)
removed = delete_custom_override(
self._owner_kind, self._owner_id, scope=scope, lang=lang
)
return json.dumps(
{
"status": "success",
+17 -4
View File
@@ -51,7 +51,9 @@ class DocsController:
response.raise_for_status()
markdown = response.text
except httpx.HTTPError as exc:
raise NetworkError(f"Could not load documentation: {exc}", url=url) from exc
raise NetworkError(
f"Could not load documentation: {exc}", url=url
) from exc
self._sections = self._split(markdown)
logger.info("Loaded %d documentation sections", len(self._sections))
return self._sections
@@ -78,7 +80,9 @@ class DocsController:
query = str(arguments.get("query", "")).strip()
if not query:
raise ToolInputError("search_docs requires a query.")
max_results = int(arguments.get("max_results", DEFAULT_MAX_RESULTS) or DEFAULT_MAX_RESULTS)
max_results = int(
arguments.get("max_results", DEFAULT_MAX_RESULTS) or DEFAULT_MAX_RESULTS
)
max_results = max(1, min(max_results, 10))
sections = await self._load()
@@ -100,10 +104,19 @@ class DocsController:
scored.sort(key=lambda item: item[0], reverse=True)
results = [
{"title": heading, "score": round(score, 2), "content": truncate(text, SECTION_CHARS)}
{
"title": heading,
"score": round(score, 2),
"content": truncate(text, SECTION_CHARS),
}
for score, heading, text in scored[:max_results]
]
return json.dumps(
{"status": "success", "query": query, "count": len(results), "results": results},
{
"status": "success",
"query": query,
"count": len(results),
"results": results,
},
ensure_ascii=False,
)
@@ -94,7 +94,11 @@ class FetchController:
body, final_url, content_type, status = await self._download(url)
if "html" in content_type or "xml" in content_type or content_type.startswith("text/"):
if (
"html" in content_type
or "xml" in content_type
or content_type.startswith("text/")
):
title_match = TITLE.search(body)
title = html_to_text(title_match.group(1)) if title_match else ""
content = html_to_text(body)
+13 -5
View File
@@ -12,12 +12,14 @@ from .errors import AuthRequiredError, NetworkError, ToolInputError, UpstreamErr
logger = logging.getLogger("devii.http")
LOGIN_FAILURE_MARKERS = ("Invalid email or password", "name=\"password\"")
LOGIN_FAILURE_MARKERS = ("Invalid email or password", 'name="password"')
LOGIN_PATH = "/auth/login"
class PlatformClient:
def __init__(self, base_url: str, timeout_seconds: float, api_key: str = "") -> None:
def __init__(
self, base_url: str, timeout_seconds: float, api_key: str = ""
) -> None:
headers = {"User-Agent": "devii/0.1", "Accept": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
@@ -37,7 +39,9 @@ class PlatformClient:
def session_cookie(self) -> str:
return self._client.cookies.get("session") or ""
async def login(self, email: str, password: str, remember_me: bool = True) -> dict[str, Any]:
async def login(
self, email: str, password: str, remember_me: bool = True
) -> dict[str, Any]:
form = {
"email": email,
"password": password,
@@ -136,8 +140,12 @@ class PlatformClient:
logger.debug("Response %s %s -> %s", method, path, response.status_code)
return response
except httpx.TimeoutException as exc:
raise NetworkError(f"Request timed out: {method} {path}", path=path) from exc
raise NetworkError(
f"Request timed out: {method} {path}", path=path
) from exc
except httpx.ConnectError as exc:
raise NetworkError(f"Could not connect: {method} {path}", path=path) from exc
raise NetworkError(
f"Could not connect: {method} {path}", path=path
) from exc
except httpx.HTTPError as exc:
raise NetworkError(f"HTTP error: {exc}", path=path) from exc
+23 -4
View File
@@ -36,7 +36,12 @@ class DeviiHub:
return self._stores["ledger"]
def get_or_create(
self, owner_kind: str, owner_id: str, username: str, api_key: str, base_url: str,
self,
owner_kind: str,
owner_id: str,
username: str,
api_key: str,
base_url: str,
is_admin: bool = False,
) -> DeviiSession:
key = (owner_kind, owner_id)
@@ -51,15 +56,29 @@ class DeviiHub:
lessons = LessonStore(owned_db, owner_kind, owner_id)
virtual_tool_store = VirtualToolStore(owned_db, owner_kind, owner_id)
session = DeviiSession(
owner_kind, owner_id, username, settings, llm, lessons, pricing, task_store,
virtual_tool_store, self._stores, is_admin=is_admin,
owner_kind,
owner_id,
username,
settings,
llm,
lessons,
pricing,
task_store,
virtual_tool_store,
self._stores,
is_admin=is_admin,
)
if owner_kind == "user":
saved = self._stores["conversations"].load(owner_kind, owner_id)
if saved:
session.restore_history(saved)
self._sessions[key] = session
logger.info("Created session %s/%s (total %d)", owner_kind, owner_id, len(self._sessions))
logger.info(
"Created session %s/%s (total %d)",
owner_kind,
owner_id,
len(self._sessions),
)
return session
def find(self, owner_kind: str, owner_id: str) -> DeviiSession | None:
+13 -4
View File
@@ -66,14 +66,19 @@ class LLMClient:
raise LLMError("Model response contained no message.", body=str(data)[:500])
record_usage(data.get("usage"))
logger.debug("LLM response received (tool_calls=%s)", bool(message.get("tool_calls")))
logger.debug(
"LLM response received (tool_calls=%s)", bool(message.get("tool_calls"))
)
return message
async def summarize(self, text: str) -> str:
payload = {
"model": self._settings.ai_model,
"messages": [
{"role": "system", "content": "You are a precise technical summarizer."},
{
"role": "system",
"content": "You are a precise technical summarizer.",
},
{"role": "user", "content": text},
],
"temperature": 0.0,
@@ -83,12 +88,16 @@ class LLMClient:
except httpx.HTTPError as exc:
raise LLMError(f"Could not reach the model endpoint: {exc}") from exc
if response.status_code >= 400:
raise LLMError(f"Model endpoint returned {response.status_code}: {self._reason(response)}")
raise LLMError(
f"Model endpoint returned {response.status_code}: {self._reason(response)}"
)
try:
data = response.json()
content = data["choices"][0]["message"]["content"] or ""
except (ValueError, KeyError, IndexError) as exc:
raise LLMError("Model summarization returned an unexpected response.") from exc
raise LLMError(
"Model summarization returned an unexpected response."
) from exc
record_usage(data.get("usage"))
return content
+51 -32
View File
@@ -19,7 +19,9 @@ USER_AGENT = "devii/0.1"
def _flag(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return "true" if str(value).strip().lower() in ("1", "true", "yes", "on") else "false"
return (
"true" if str(value).strip().lower() in ("1", "true", "yes", "on") else "false"
)
class RsearchController:
@@ -65,7 +67,9 @@ class RsearchController:
try:
return response.json()
except ValueError as exc:
raise UpstreamError("rsearch returned a non-JSON response", path=path) from exc
raise UpstreamError(
"rsearch returned a non-JSON response", path=path
) from exc
def _require_text(self, arguments: dict[str, Any], key: str) -> str:
value = str(arguments.get(key, "")).strip()
@@ -105,14 +109,17 @@ class RsearchController:
}
for item in results
]
return json.dumps({
"status": "success",
"query": data.get("query", query),
"type": result_type,
"source": data.get("source"),
"count": len(compact),
"results": compact,
}, ensure_ascii=False)
return json.dumps(
{
"status": "success",
"query": data.get("query", query),
"type": result_type,
"source": data.get("source"),
"count": len(compact),
"results": compact,
},
ensure_ascii=False,
)
async def _answer(self, arguments: dict[str, Any]) -> str:
query = self._require_text(arguments, "query")
@@ -124,36 +131,48 @@ class RsearchController:
}
data = await self._request("/search", params)
results = data.get("results") or []
return json.dumps({
"status": "success",
"query": query,
"answer": data.get("ai_response"),
"answer_error": data.get("ai_error"),
"sources": [
{"title": item.get("title"), "url": item.get("url")}
for item in results
],
}, ensure_ascii=False)
return json.dumps(
{
"status": "success",
"query": query,
"answer": data.get("ai_response"),
"answer_error": data.get("ai_error"),
"sources": [
{"title": item.get("title"), "url": item.get("url")}
for item in results
],
},
ensure_ascii=False,
)
async def _chat(self, arguments: dict[str, Any]) -> str:
prompt = self._require_text(arguments, "prompt")
params: dict[str, Any] = {"prompt": prompt, "json": _flag(arguments.get("json"))}
params: dict[str, Any] = {
"prompt": prompt,
"json": _flag(arguments.get("json")),
}
system = str(arguments.get("system", "")).strip()
if system:
params["system"] = system
data = await self._request("/chat", params)
return json.dumps({
"status": "success",
"response": data.get("response"),
"json_mode": data.get("json_mode", False),
}, ensure_ascii=False)
return json.dumps(
{
"status": "success",
"response": data.get("response"),
"json_mode": data.get("json_mode", False),
},
ensure_ascii=False,
)
async def _describe(self, arguments: dict[str, Any]) -> str:
url = self._require_text(arguments, "url")
data = await self._request("/describe", {"url": url})
return json.dumps({
"status": "success",
"url": url,
"mime_type": data.get("mime_type"),
"description": data.get("description"),
}, ensure_ascii=False)
return json.dumps(
{
"status": "success",
"url": url,
"mime_type": data.get("mime_type"),
"description": data.get("description"),
},
ensure_ascii=False,
)
+214 -67
View File
@@ -29,61 +29,183 @@ class DeviiService(BaseService):
"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_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_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_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",
),
]
def __init__(self):
@@ -93,15 +215,19 @@ class DeviiService(BaseService):
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())
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:
@@ -119,7 +245,13 @@ class DeviiService(BaseService):
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):
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)
@@ -149,7 +281,9 @@ class DeviiService(BaseService):
pruned = hub.ledger.prune(48)
removed = await hub.gc_idle()
if pruned or removed:
self.log(f"Housekeeping: pruned {pruned} ledger rows, closed {removed} idle sessions")
self.log(
f"Housekeeping: pruned {pruned} ledger rows, closed {removed} idle sessions"
)
async def on_disable(self) -> None:
if self._hub is not None:
@@ -167,13 +301,26 @@ class DeviiService(BaseService):
{"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": "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]},
]
}
+120 -41
View File
@@ -66,7 +66,9 @@ class DeviiSession:
self.persist_conversation = owner_kind == "user"
self._llm = llm
self._lessons = lessons
self.client = PlatformClient(settings.base_url, settings.timeout_seconds, settings.platform_api_key)
self.client = PlatformClient(
settings.base_url, settings.timeout_seconds, settings.platform_api_key
)
self.avatar = AvatarController()
self.browser = ClientController(allow_eval=settings.allow_eval)
self.store = task_store
@@ -79,25 +81,45 @@ class DeviiSession:
virtual_tool_store, self.agentic.run_subagent, set(CATALOG.by_name())
)
self.dispatcher = Dispatcher(
CATALOG, self.client, settings, self.task_controller, self.agentic,
avatar=self.avatar, browser=self.browser,
is_admin=is_admin, quota_provider=self._quota_snapshot,
owner_kind=owner_kind, owner_id=owner_id,
CATALOG,
self.client,
settings,
self.task_controller,
self.agentic,
avatar=self.avatar,
browser=self.browser,
is_admin=is_admin,
quota_provider=self._quota_snapshot,
owner_kind=owner_kind,
owner_id=owner_id,
virtual_tools=self.virtual_tools,
)
self.tools = CATALOG.tool_schemas_for(self.client.authenticated, is_admin)
self._system_prompt = _system_prompt_for(is_admin)
self.agentic.bind(
llm=llm, dispatcher=self.dispatcher, tools=self.tools,
on_trace=self._trace, cost_tracker=self.cost, chunk_store=self.chunks,
llm=llm,
dispatcher=self.dispatcher,
tools=self.tools,
on_trace=self._trace,
cost_tracker=self.cost,
chunk_store=self.chunks,
)
self.agent = Agent(
settings, llm, self.dispatcher, self.tools, lessons=lessons,
on_trace=self._trace, cost_tracker=self.cost, chunk_store=self.chunks,
settings,
llm,
self.dispatcher,
self.tools,
lessons=lessons,
on_trace=self._trace,
cost_tracker=self.cost,
chunk_store=self.chunks,
system_prompt=self._system_prompt,
)
self.scheduler = Scheduler(
self.store, self._make_executor(), self._task_event, settings.scheduler_tick_seconds
self.store,
self._make_executor(),
self._task_event,
settings.scheduler_tick_seconds,
)
self._conv = stores["conversations"]
self._ledger = stores["ledger"]
@@ -129,7 +151,11 @@ class DeviiSession:
content = message.get("content")
if role not in ("user", "assistant") or not content:
continue
if role == "user" and isinstance(content, str) and content.startswith(INTERNAL_PREFIXES):
if (
role == "user"
and isinstance(content, str)
and content.startswith(INTERNAL_PREFIXES)
):
continue
visible.append({"role": role, "content": content})
return visible
@@ -138,8 +164,14 @@ class DeviiSession:
async def execute(prompt: str) -> str:
async with self._lock:
worker = Agent(
self.settings, self._llm, self.dispatcher, self.tools, lessons=self._lessons,
on_trace=self._trace, cost_tracker=self.cost, chunk_store=self.chunks,
self.settings,
self._llm,
self.dispatcher,
self.tools,
lessons=self._lessons,
on_trace=self._trace,
cost_tracker=self.cost,
chunk_store=self.chunks,
system_prompt=self._system_prompt,
)
return await worker.respond(prompt)
@@ -149,7 +181,11 @@ class DeviiSession:
def attach(self, ws: Any) -> None:
self._conns.add(ws)
self._attach_seq += 1
self._conn_meta[ws] = {"seq": self._attach_seq, "visible": True, "focused": False}
self._conn_meta[ws] = {
"seq": self._attach_seq,
"visible": True,
"focused": False,
}
self._connected.set()
self._disconnected.clear()
self.avatar.bind(self._avatar_request)
@@ -161,7 +197,12 @@ class DeviiSession:
pending = self._buffer
self._buffer = []
asyncio.create_task(self._flush(pending))
logger.info("Session %s/%s attached (%d conns)", self.owner_kind, self.owner_id, len(self._conns))
logger.info(
"Session %s/%s attached (%d conns)",
self.owner_kind,
self.owner_id,
len(self._conns),
)
def detach(self, ws: Any) -> None:
self._conns.discard(ws)
@@ -171,7 +212,12 @@ class DeviiSession:
self._disconnected.set()
self.avatar.unbind()
self.browser.unbind()
logger.info("Session %s/%s detached (%d conns)", self.owner_kind, self.owner_id, len(self._conns))
logger.info(
"Session %s/%s detached (%d conns)",
self.owner_kind,
self.owner_id,
len(self._conns),
)
def set_visibility(self, ws: Any, visible: bool, focused: bool) -> None:
meta = self._conn_meta.get(ws)
@@ -212,7 +258,9 @@ class DeviiSession:
if history:
await self._send_to(ws, {"type": "history", "messages": history})
else:
await self._send_to(ws, {"type": "reply", "text": await self.bootstrap_greeting()})
await self._send_to(
ws, {"type": "reply", "text": await self.bootstrap_greeting()}
)
async def reset_conversation(self) -> None:
async with self._lock:
@@ -227,7 +275,11 @@ class DeviiSession:
try:
self._conv.clear(self.owner_kind, self.owner_id)
except Exception: # noqa: BLE001 - clearing storage must not break the socket
logger.exception("Failed to clear conversation for %s/%s", self.owner_kind, self.owner_id)
logger.exception(
"Failed to clear conversation for %s/%s",
self.owner_kind,
self.owner_id,
)
await self._emit({"type": "clear"}, buffer=False)
def spawn_turn(self, text: str) -> None:
@@ -296,31 +348,44 @@ class DeviiSession:
after = self._cost_snapshot()
usage = {
"prompt_tokens": after["prompt_tokens"] - before["prompt_tokens"],
"completion_tokens": after["completion_tokens"] - before["completion_tokens"],
"completion_tokens": after["completion_tokens"]
- before["completion_tokens"],
"cache_hit_tokens": after["cache_hit_tokens"] - before["cache_hit_tokens"],
"cache_miss_tokens": after["cache_miss_tokens"] - before["cache_miss_tokens"],
"cache_miss_tokens": after["cache_miss_tokens"]
- before["cache_miss_tokens"],
}
cost_delta = round(after["cost_usd"] - before["cost_usd"], 8)
try:
if self.persist_conversation:
self._conv.save(self.owner_kind, self.owner_id, self.agent._messages)
self._ledger.record(self.owner_kind, self.owner_id, turn_id, usage, cost_delta, self.settings.ai_model)
self._audit.record({
"turn_id": turn_id,
"owner_kind": self.owner_kind,
"owner_id": self.owner_id,
"username": self.username,
"started_at": started_at,
"ended_at": _now_iso(),
"prompt": prompt,
"reply": reply,
"iterations": after["requests"] - before["requests"],
"tool_calls": self._turn_tool_calls,
"cost_usd": cost_delta,
"error": error,
})
self._ledger.record(
self.owner_kind,
self.owner_id,
turn_id,
usage,
cost_delta,
self.settings.ai_model,
)
self._audit.record(
{
"turn_id": turn_id,
"owner_kind": self.owner_kind,
"owner_id": self.owner_id,
"username": self.username,
"started_at": started_at,
"ended_at": _now_iso(),
"prompt": prompt,
"reply": reply,
"iterations": after["requests"] - before["requests"],
"tool_calls": self._turn_tool_calls,
"cost_usd": cost_delta,
"error": error,
}
)
except Exception: # noqa: BLE001 - persistence must never break a turn
logger.exception("Failed to persist turn for %s/%s", self.owner_kind, self.owner_id)
logger.exception(
"Failed to persist turn for %s/%s", self.owner_kind, self.owner_id
)
def resolve_query(self, query_id: str, payload: Any) -> None:
future = self._pending.pop(query_id, None)
@@ -347,10 +412,14 @@ class DeviiSession:
token = self.client.session_cookie()
if token:
self._pending_session = token
asyncio.create_task(self._emit({"type": "auth", "action": "adopt"}, buffer=False))
asyncio.create_task(
self._emit({"type": "auth", "action": "adopt"}, buffer=False)
)
elif name == "logout":
self._pending_session = None
asyncio.create_task(self._emit({"type": "auth", "action": "logout"}, buffer=False))
asyncio.create_task(
self._emit({"type": "auth", "action": "logout"}, buffer=False)
)
def take_pending_session(self) -> str | None:
token = self._pending_session
@@ -381,7 +450,9 @@ class DeviiSession:
async def _client_request(self, action: str, args: dict[str, Any]) -> Any:
return await self._browser_request("client", action, args)
async def _browser_request(self, channel: str, action: str, args: dict[str, Any]) -> Any:
async def _browser_request(
self, channel: str, action: str, args: dict[str, Any]
) -> Any:
loop = asyncio.get_event_loop()
deadline = loop.time() + BROWSER_REQUEST_DEADLINE_SECONDS
self._query_seq += 1
@@ -397,7 +468,9 @@ class DeviiSession:
try:
await asyncio.wait_for(self._connected.wait(), timeout=remaining)
except asyncio.TimeoutError as exc:
raise RuntimeError(f"No browser connected to handle '{action}'") from exc
raise RuntimeError(
f"No browser connected to handle '{action}'"
) from exc
ws = self._pick_target()
if ws is None:
continue
@@ -423,7 +496,12 @@ class DeviiSession:
def _task_event(self, kind: str, row: dict[str, Any], payload: str) -> None:
label = row.get("label") or row.get("uid", "task")
message = {"type": "task", "kind": kind, "label": label, "payload": payload[:RESULT_NOTICE_CHARS]}
message = {
"type": "task",
"kind": kind,
"label": label,
"payload": payload[:RESULT_NOTICE_CHARS],
}
asyncio.create_task(self._emit(message, buffer=True))
@@ -443,4 +521,5 @@ def _system_prompt_for(is_admin: bool) -> str:
def _now_iso() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()
+31 -17
View File
@@ -31,7 +31,9 @@ class ConversationStore:
def load(self, owner_kind: str, owner_id: str) -> list[dict[str, Any]] | None:
if CONVERSATIONS not in db.tables:
return None
row = get_table(CONVERSATIONS).find_one(owner_kind=owner_kind, owner_id=owner_id)
row = get_table(CONVERSATIONS).find_one(
owner_kind=owner_kind, owner_id=owner_id
)
if not row or not row.get("messages"):
return None
try:
@@ -40,7 +42,9 @@ class ConversationStore:
logger.warning("Corrupt conversation for %s/%s", owner_kind, owner_id)
return None
def save(self, owner_kind: str, owner_id: str, messages: list[dict[str, Any]]) -> None:
def save(
self, owner_kind: str, owner_id: str, messages: list[dict[str, Any]]
) -> None:
now = _iso(_now())
record = {
"owner_kind": owner_kind,
@@ -49,7 +53,11 @@ class ConversationStore:
"updated_at": now,
}
table = get_table(CONVERSATIONS)
existing = table.find_one(owner_kind=owner_kind, owner_id=owner_id) if CONVERSATIONS in db.tables else None
existing = (
table.find_one(owner_kind=owner_kind, owner_id=owner_id)
if CONVERSATIONS in db.tables
else None
)
if existing:
table.update({**record, "id": existing["id"]}, ["id"])
else:
@@ -71,18 +79,20 @@ class UsageLedger:
cost_usd: float,
model: str,
) -> None:
get_table(LEDGER).insert({
"owner_kind": owner_kind,
"owner_id": owner_id,
"turn_id": turn_id,
"created_at": _iso(_now()),
"prompt_tokens": int(usage.get("prompt_tokens", 0)),
"completion_tokens": int(usage.get("completion_tokens", 0)),
"cache_hit_tokens": int(usage.get("cache_hit_tokens", 0)),
"cache_miss_tokens": int(usage.get("cache_miss_tokens", 0)),
"cost_usd": float(cost_usd),
"model": model,
})
get_table(LEDGER).insert(
{
"owner_kind": owner_kind,
"owner_id": owner_id,
"turn_id": turn_id,
"created_at": _iso(_now()),
"prompt_tokens": int(usage.get("prompt_tokens", 0)),
"completion_tokens": int(usage.get("completion_tokens", 0)),
"cache_hit_tokens": int(usage.get("cache_hit_tokens", 0)),
"cache_miss_tokens": int(usage.get("cache_miss_tokens", 0)),
"cost_usd": float(cost_usd),
"model": model,
}
)
def spent_24h(self, owner_kind: str, owner_id: str) -> float:
if LEDGER not in db.tables:
@@ -91,7 +101,9 @@ class UsageLedger:
rows = db.query(
f"SELECT COALESCE(SUM(cost_usd), 0) AS total FROM {LEDGER} "
"WHERE owner_kind = :owner_kind AND owner_id = :owner_id AND created_at >= :cutoff",
owner_kind=owner_kind, owner_id=owner_id, cutoff=cutoff,
owner_kind=owner_kind,
owner_id=owner_id,
cutoff=cutoff,
)
for row in rows:
return float(row["total"] or 0.0)
@@ -104,7 +116,9 @@ class UsageLedger:
rows = db.query(
f"SELECT COUNT(*) AS n FROM {LEDGER} "
"WHERE owner_kind = :owner_kind AND owner_id = :owner_id AND created_at >= :cutoff",
owner_kind=owner_kind, owner_id=owner_id, cutoff=cutoff,
owner_kind=owner_kind,
owner_id=owner_id,
cutoff=cutoff,
)
for row in rows:
return int(row["n"] or 0)
+23 -5
View File
@@ -5,8 +5,16 @@ from __future__ import annotations
from ..actions.spec import Action, Param
def field(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
return Param(name=name, location="body", description=description, required=required, type=kind)
def field(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
SCHEDULE_FIELDS: tuple[Param, ...] = (
@@ -20,8 +28,16 @@ SCHEDULE_FIELDS: tuple[Param, ...] = (
"run_at",
"For kind=once: absolute UTC time in ISO 8601, e.g. 2026-06-09T14:30:00.",
),
field("delay_seconds", "For kind=once: run this many seconds from now.", kind="integer"),
field("every_seconds", "For kind=interval: number of seconds between runs.", kind="integer"),
field(
"delay_seconds",
"For kind=once: run this many seconds from now.",
kind="integer",
),
field(
"every_seconds",
"For kind=interval: number of seconds between runs.",
kind="integer",
),
field(
"start_at",
"For kind=interval: optional ISO 8601 UTC time of the first run. "
@@ -73,7 +89,9 @@ TASK_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
params=(
field("enabled_only", "Only return enabled tasks.", kind="boolean"),
field("status", "Filter by status: pending, running, done, error, disabled."),
field(
"status", "Filter by status: pending, running, done, error, disabled."
),
),
),
Action(
+42 -10
View File
@@ -15,7 +15,15 @@ from .store import TaskStore
logger = logging.getLogger("devii.tasks.controller")
SCHEDULE_KEYS = ("kind", "run_at", "delay_seconds", "every_seconds", "start_at", "cron", "max_runs")
SCHEDULE_KEYS = (
"kind",
"run_at",
"delay_seconds",
"every_seconds",
"start_at",
"cron",
"max_runs",
)
RESULT_PREVIEW_CHARS = 500
TRUTHY = {"1", "true", "yes", "on"}
@@ -105,7 +113,10 @@ class TaskController:
)
rows.sort(key=lambda row: row.get("next_run_at") or "")
return json.dumps(
{"count": len(rows), "tasks": [_serialize(row, preview=True) for row in rows]},
{
"count": len(rows),
"tasks": [_serialize(row, preview=True) for row in rows],
},
ensure_ascii=False,
)
@@ -127,7 +138,9 @@ class TaskController:
if "enabled" in arguments:
changes["enabled"] = _as_bool(arguments.get("enabled"))
if any(key in arguments and arguments[key] is not None for key in SCHEDULE_KEYS):
if any(
key in arguments and arguments[key] is not None for key in SCHEDULE_KEYS
):
merged = {key: row.get(key) for key in SCHEDULE_KEYS}
for key in SCHEDULE_KEYS:
if key in arguments and arguments[key] is not None:
@@ -137,10 +150,16 @@ class TaskController:
changes["next_run_at"] = to_iso(schedule.first_run(now_utc()))
changes["status"] = "pending"
if changes.get("enabled") and row.get("status") in ("done", "disabled", "error"):
if changes.get("enabled") and row.get("status") in (
"done",
"disabled",
"error",
):
changes.setdefault("status", "pending")
if not changes.get("next_run_at") and not row.get("next_run_at"):
schedule = self._build_schedule({key: row.get(key) for key in SCHEDULE_KEYS})
schedule = self._build_schedule(
{key: row.get(key) for key in SCHEDULE_KEYS}
)
changes["next_run_at"] = to_iso(schedule.first_run(now_utc()))
if changes.get("enabled") is False:
changes["status"] = "disabled"
@@ -150,7 +169,10 @@ class TaskController:
self._store.update(row["uid"], changes)
return json.dumps(
{"status": "updated", "task": _serialize(self._store.get(row["uid"]), preview=True)},
{
"status": "updated",
"task": _serialize(self._store.get(row["uid"]), preview=True),
},
ensure_ascii=False,
)
@@ -168,12 +190,18 @@ class TaskController:
{"enabled": True, "status": "pending", "next_run_at": to_iso(now_utc())},
)
return json.dumps(
{"status": "queued", "uid": row["uid"], "note": "Will execute on the next scheduler tick."},
{
"status": "queued",
"uid": row["uid"],
"note": "Will execute on the next scheduler tick.",
},
ensure_ascii=False,
)
def _build_schedule(self, source: dict[str, Any]) -> Schedule:
payload = {key: source.get(key) for key in SCHEDULE_KEYS if source.get(key) is not None}
payload = {
key: source.get(key) for key in SCHEDULE_KEYS if source.get(key) is not None
}
try:
return Schedule(**payload)
except ValidationError as exc:
@@ -195,10 +223,14 @@ class TaskController:
return row
def compute_followup(row: dict[str, Any], reference: Any) -> tuple[dict[str, Any], bool]:
def compute_followup(
row: dict[str, Any], reference: Any
) -> tuple[dict[str, Any], bool]:
run_count = int(row.get("run_count") or 0) + 1
max_runs = row.get("max_runs")
upcoming = next_run(row.get("kind"), row.get("every_seconds"), row.get("cron"), reference)
upcoming = next_run(
row.get("kind"), row.get("every_seconds"), row.get("cron"), reference
)
changes: dict[str, Any] = {"run_count": run_count, "last_run_at": to_iso(reference)}
if upcoming is None or (max_runs is not None and run_count >= int(max_runs)):
+6 -2
View File
@@ -50,7 +50,9 @@ def _parse_field(field: str, low: int, high: int) -> set[int]:
def cron_next(expr: str, after: datetime) -> datetime:
fields = expr.split()
if len(fields) != 5:
raise ValueError("Cron expression must have 5 fields: minute hour dom month dow")
raise ValueError(
"Cron expression must have 5 fields: minute hour dom month dow"
)
minutes = _parse_field(fields[0], 0, 59)
hours = _parse_field(fields[1], 0, 23)
days = _parse_field(fields[2], 1, 31)
@@ -139,7 +141,9 @@ class Schedule(BaseModel):
}
def next_run(kind: str, every_seconds: Optional[int], cron: Optional[str], reference: datetime) -> Optional[datetime]:
def next_run(
kind: str, every_seconds: Optional[int], cron: Optional[str], reference: datetime
) -> Optional[datetime]:
if kind == "interval" and every_seconds:
return reference + timedelta(seconds=int(every_seconds))
if kind == "cron" and cron:
+5 -1
View File
@@ -77,7 +77,11 @@ class Scheduler:
logger.exception("Task uid=%s crashed", uid)
self._store.update(
uid,
{"status": "error", "last_error": str(exc), "last_run_at": to_iso(now_utc())},
{
"status": "error",
"last_error": str(exc),
"last_run_at": to_iso(now_utc()),
},
)
self._on_event("error", row, str(exc))
return
+25 -6
View File
@@ -10,7 +10,13 @@ import dataset
logger = logging.getLogger("devii.tasks.store")
TABLE = "devii_tasks"
INDEXED_COLUMNS = (["owner_kind", "owner_id"], ["uid"], ["enabled"], ["next_run_at"], ["status"])
INDEXED_COLUMNS = (
["owner_kind", "owner_id"],
["uid"],
["enabled"],
["next_run_at"],
["status"],
)
def memory_db() -> Any:
@@ -41,12 +47,19 @@ class TaskStore:
def create(self, record: dict[str, Any]) -> None:
self._table.insert({**record, **self._scope})
logger.info("Task created uid=%s owner=%s/%s", record.get("uid"), self._owner_kind, self._owner_id)
logger.info(
"Task created uid=%s owner=%s/%s",
record.get("uid"),
self._owner_kind,
self._owner_id,
)
def get(self, uid: str) -> dict[str, Any] | None:
return self._table.find_one(uid=uid, **self._scope)
def list(self, enabled_only: bool = False, status: str | None = None) -> list[dict[str, Any]]:
def list(
self, enabled_only: bool = False, status: str | None = None
) -> list[dict[str, Any]]:
criteria: dict[str, Any] = dict(self._scope)
if enabled_only:
criteria["enabled"] = True
@@ -69,12 +82,18 @@ class TaskStore:
return 0
stuck = list(self._table.find(status="running", **self._scope))
for row in stuck:
self._table.update({"uid": row["uid"], "status": "pending", **self._scope},
["uid", "owner_kind", "owner_id"])
self._table.update(
{"uid": row["uid"], "status": "pending", **self._scope},
["uid", "owner_kind", "owner_id"],
)
if stuck:
logger.info("Recovered %d task(s) stuck in running", len(stuck))
return len(stuck)
def due(self, now_iso: str) -> list[dict[str, Any]]:
rows = self._table.find(enabled=True, status="pending", **self._scope)
return [row for row in rows if row.get("next_run_at") and row["next_run_at"] <= now_iso]
return [
row
for row in rows
if row.get("next_run_at") and row["next_run_at"] <= now_iso
]
+13 -7
View File
@@ -19,12 +19,14 @@ TRAILING_SPACE = re.compile(r"[ \t]+\n")
SKIP_HREF_PREFIXES = ("#", "javascript:")
HIDDEN = "[hidden]"
REDACT_FIELD_KEYS = frozenset({
"gateway_upstream_url",
"gateway_model",
"gateway_vision_url",
"gateway_vision_model",
})
REDACT_FIELD_KEYS = frozenset(
{
"gateway_upstream_url",
"gateway_model",
"gateway_vision_url",
"gateway_vision_model",
}
)
REDACT_STAT_LABELS = frozenset({"Model"})
@@ -80,7 +82,11 @@ def format_response(response: httpx.Response) -> str:
summary["body"] = response.text
return json.dumps(summary, ensure_ascii=False)
if content_type.startswith("text/") or "html" in content_type or "xml" in content_type:
if (
content_type.startswith("text/")
or "html" in content_type
or "xml" in content_type
):
summary["body"] = html_to_text(response.text)
return json.dumps(summary, ensure_ascii=False)
@@ -5,8 +5,16 @@ from __future__ import annotations
from ..actions.spec import Action, Param
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
return Param(name=name, location="body", description=description, required=required, type=kind)
def arg(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
VIRTUAL_TOOL_ACTIONS: tuple[Action, ...] = (
@@ -23,10 +31,25 @@ VIRTUAL_TOOL_ACTIONS: tuple[Action, ...] = (
handler="virtual_tool",
requires_auth=False,
params=(
arg("name", "Tool name: a letter then letters, digits, or underscores (2-41 chars).", required=True),
arg("description", "When to use the tool and what it does (your trigger hint).", required=True),
arg("prompt", "The instruction the tool runs each time it is called.", required=True),
arg("input_description", "What the single free-form 'input' argument should contain."),
arg(
"name",
"Tool name: a letter then letters, digits, or underscores (2-41 chars).",
required=True,
),
arg(
"description",
"When to use the tool and what it does (your trigger hint).",
required=True,
),
arg(
"prompt",
"The instruction the tool runs each time it is called.",
required=True,
),
arg(
"input_description",
"What the single free-form 'input' argument should contain.",
),
),
),
Action(
@@ -58,7 +81,11 @@ VIRTUAL_TOOL_ACTIONS: tuple[Action, ...] = (
arg("description", "New description (optional)."),
arg("prompt", "New stored prompt (optional)."),
arg("input_description", "New input hint (optional)."),
arg("enabled", "Enable (true) or disable (false) the tool without deleting it.", kind="boolean"),
arg(
"enabled",
"Enable (true) or disable (false) the tool without deleting it.",
kind="boolean",
),
),
),
Action(
@@ -35,7 +35,9 @@ def _serialize(row: dict[str, Any], full: bool = False) -> dict[str, Any]:
class VirtualToolController:
def __init__(self, store: VirtualToolStore, evaluator: Evaluator, builtin_names: set[str]) -> None:
def __init__(
self, store: VirtualToolStore, evaluator: Evaluator, builtin_names: set[str]
) -> None:
self._store = store
self._evaluator = evaluator
self._builtin_names = builtin_names
@@ -65,7 +67,9 @@ class VirtualToolController:
prompt = row.get("prompt") or ""
composed = f"{prompt}\n\nUser input: {user_input}" if user_input else prompt
result = await self._evaluator(composed)
return json.dumps({"status": "success", "tool": name, "result": result}, ensure_ascii=False)
return json.dumps(
{"status": "success", "tool": name, "result": result}, ensure_ascii=False
)
def _require_name(self, arguments: dict[str, Any]) -> str:
name = str(arguments.get("name", "")).strip()
@@ -80,15 +84,23 @@ class VirtualToolController:
"Tool name must be 2-41 characters: a letter followed by letters, digits, or underscores."
)
if name in self._builtin_names:
raise ToolInputError(f"'{name}' is a built-in tool name; choose a different name.")
raise ToolInputError(
f"'{name}' is a built-in tool name; choose a different name."
)
if self._store.find(name):
raise ToolInputError(f"A tool named '{name}' already exists; use tool_update to change it.")
raise ToolInputError(
f"A tool named '{name}' already exists; use tool_update to change it."
)
description = str(arguments.get("description", "")).strip()
prompt = str(arguments.get("prompt", "")).strip()
if not description:
raise ToolInputError("A 'description' is required so the tool can be triggered correctly.")
raise ToolInputError(
"A 'description' is required so the tool can be triggered correctly."
)
if not prompt:
raise ToolInputError("A 'prompt' is required: the instruction the tool runs when called.")
raise ToolInputError(
"A 'prompt' is required: the instruction the tool runs when called."
)
record = {
"uid": uuid.uuid4().hex,
"name": name,
@@ -98,12 +110,15 @@ class VirtualToolController:
"enabled": 1,
}
stored = self._store.create(record)
return json.dumps({"status": "created", "tool": _serialize(stored)}, ensure_ascii=False)
return json.dumps(
{"status": "created", "tool": _serialize(stored)}, ensure_ascii=False
)
def _list(self, arguments: dict[str, Any]) -> str:
rows = self._store.list()
return json.dumps(
{"count": len(rows), "tools": [_serialize(row) for row in rows]}, ensure_ascii=False
{"count": len(rows), "tools": [_serialize(row) for row in rows]},
ensure_ascii=False,
)
def _get(self, arguments: dict[str, Any]) -> str:
@@ -126,11 +141,21 @@ class VirtualToolController:
if "input_description" in arguments:
changes["input_description"] = str(arguments["input_description"]).strip()
if "enabled" in arguments and arguments["enabled"] is not None:
changes["enabled"] = 1 if str(arguments["enabled"]).strip().lower() in ("1", "true", "yes", "on") else 0
changes["enabled"] = (
1
if str(arguments["enabled"]).strip().lower()
in ("1", "true", "yes", "on")
else 0
)
if not changes:
raise ToolInputError("No updatable fields supplied (description, prompt, input_description, enabled).")
raise ToolInputError(
"No updatable fields supplied (description, prompt, input_description, enabled)."
)
self._store.update(name, changes)
return json.dumps({"status": "updated", "tool": _serialize(self._store.find(name))}, ensure_ascii=False)
return json.dumps(
{"status": "updated", "tool": _serialize(self._store.find(name))},
ensure_ascii=False,
)
def _delete(self, arguments: dict[str, Any]) -> str:
name = self._require_name(arguments)
@@ -71,7 +71,12 @@ class VirtualToolStore:
now = _now_iso()
record = {**record, **self._scope, "created_at": now, "updated_at": now}
self._table.insert(record)
logger.info("Virtual tool created name=%s owner=%s/%s", record.get("name"), self._owner_kind, self._owner_id)
logger.info(
"Virtual tool created name=%s owner=%s/%s",
record.get("name"),
self._owner_kind,
self._owner_id,
)
return record
def update(self, name: str, changes: dict[str, Any]) -> None:
+102 -55
View File
@@ -28,18 +28,34 @@ class JobService(BaseService):
self.concurrency_key = f"{name}_max_concurrent"
self.timeout_key = f"{name}_job_timeout_seconds"
self.config_fields = [
ConfigField(self.retention_key, "Artifact retention (seconds)", type="int",
default=DEFAULT_RETENTION_SECONDS, minimum=60,
help="Finished artifacts are deleted once unused for this long.",
group="Jobs"),
ConfigField(self.concurrency_key, "Max concurrent jobs", type="int",
default=DEFAULT_MAX_CONCURRENT, minimum=1, maximum=16,
help="Number of jobs processed in parallel.",
group="Jobs"),
ConfigField(self.timeout_key, "Job timeout (seconds)", type="int",
default=DEFAULT_JOB_TIMEOUT_SECONDS, minimum=10,
help="A running job with no live task after this long is retried.",
group="Jobs"),
ConfigField(
self.retention_key,
"Artifact retention (seconds)",
type="int",
default=DEFAULT_RETENTION_SECONDS,
minimum=60,
help="Finished artifacts are deleted once unused for this long.",
group="Jobs",
),
ConfigField(
self.concurrency_key,
"Max concurrent jobs",
type="int",
default=DEFAULT_MAX_CONCURRENT,
minimum=1,
maximum=16,
help="Number of jobs processed in parallel.",
group="Jobs",
),
ConfigField(
self.timeout_key,
"Job timeout (seconds)",
type="int",
default=DEFAULT_JOB_TIMEOUT_SECONDS,
minimum=10,
help="A running job with no live task after this long is retried.",
group="Jobs",
),
]
self._inflight: dict = {}
@@ -79,41 +95,53 @@ class JobService(BaseService):
if not task.done():
continue
del self._inflight[uid]
duration_ms = int((datetime.now(timezone.utc) - entry["started"]).total_seconds() * 1000)
duration_ms = int(
(datetime.now(timezone.utc) - entry["started"]).total_seconds() * 1000
)
exc = task.exception() if not task.cancelled() else asyncio.CancelledError()
if exc is not None:
self._finish_failed(uid, str(exc) or exc.__class__.__name__, duration_ms)
self._finish_failed(
uid, str(exc) or exc.__class__.__name__, duration_ms
)
else:
self._finish_done(uid, task.result() or {}, duration_ms)
def _finish_done(self, uid: str, result_data: dict, duration_ms: int) -> None:
now = datetime.now(timezone.utc)
get_table("jobs").update({
"uid": uid,
"status": queue.DONE,
"result": json.dumps(result_data),
"error": "",
"completed_at": now.isoformat(),
"updated_at": now.isoformat(),
"duration_ms": duration_ms,
"last_accessed_at": now.isoformat(),
"expires_at": (now + timedelta(seconds=self.retention_seconds())).isoformat(),
"bytes_in": int(result_data.get("bytes_in", 0)),
"bytes_out": int(result_data.get("bytes_out", 0)),
"item_count": int(result_data.get("item_count", 0)),
}, ["uid"])
get_table("jobs").update(
{
"uid": uid,
"status": queue.DONE,
"result": json.dumps(result_data),
"error": "",
"completed_at": now.isoformat(),
"updated_at": now.isoformat(),
"duration_ms": duration_ms,
"last_accessed_at": now.isoformat(),
"expires_at": (
now + timedelta(seconds=self.retention_seconds())
).isoformat(),
"bytes_in": int(result_data.get("bytes_in", 0)),
"bytes_out": int(result_data.get("bytes_out", 0)),
"item_count": int(result_data.get("item_count", 0)),
},
["uid"],
)
self.log(f"Job {uid} done in {duration_ms}ms")
def _finish_failed(self, uid: str, error: str, duration_ms: int) -> None:
now = datetime.now(timezone.utc)
get_table("jobs").update({
"uid": uid,
"status": queue.FAILED,
"error": error[:2000],
"completed_at": now.isoformat(),
"updated_at": now.isoformat(),
"duration_ms": duration_ms,
}, ["uid"])
get_table("jobs").update(
{
"uid": uid,
"status": queue.FAILED,
"error": error[:2000],
"completed_at": now.isoformat(),
"updated_at": now.isoformat(),
"duration_ms": duration_ms,
},
["uid"],
)
self.log(f"Job {uid} failed: {error}")
def _refill(self) -> None:
@@ -121,21 +149,31 @@ class JobService(BaseService):
if capacity <= 0:
return
table = get_table("jobs")
pending = list(table.find(kind=self.kind, status=queue.PENDING, order_by=["uid"], _limit=capacity))
pending = list(
table.find(
kind=self.kind, status=queue.PENDING, order_by=["uid"], _limit=capacity
)
)
for row in pending:
uid = row["uid"]
if uid in self._inflight:
continue
now = datetime.now(timezone.utc)
table.update({
"uid": uid,
"status": queue.RUNNING,
"started_at": now.isoformat(),
"updated_at": now.isoformat(),
"error": "",
}, ["uid"])
table.update(
{
"uid": uid,
"status": queue.RUNNING,
"started_at": now.isoformat(),
"updated_at": now.isoformat(),
"error": "",
},
["uid"],
)
job = queue.get_job(uid)
self._inflight[uid] = {"task": asyncio.create_task(self._run_job(job)), "started": now}
self._inflight[uid] = {
"task": asyncio.create_task(self._run_job(job)),
"started": now,
}
self.log(f"Job {uid} started")
async def _run_job(self, job: dict) -> dict:
@@ -148,19 +186,26 @@ class JobService(BaseService):
uid = row["uid"]
if uid in self._inflight:
continue
if min_age_seconds > 0 and not self._older_than(row.get("started_at"), now, min_age_seconds):
if min_age_seconds > 0 and not self._older_than(
row.get("started_at"), now, min_age_seconds
):
continue
retry_count = int(row.get("retry_count") or 0)
if retry_count >= self.max_retries:
self._finish_failed(uid, "exceeded retry limit after orphan recovery", 0)
self._finish_failed(
uid, "exceeded retry limit after orphan recovery", 0
)
continue
table.update({
"uid": uid,
"status": queue.PENDING,
"retry_count": retry_count + 1,
"started_at": "",
"updated_at": now.isoformat(),
}, ["uid"])
table.update(
{
"uid": uid,
"status": queue.PENDING,
"retry_count": retry_count + 1,
"started_at": "",
"updated_at": now.isoformat(),
},
["uid"],
)
self.log(f"Recovered orphaned job {uid} (retry {retry_count + 1})")
def _sweep_expired(self) -> None:
@@ -202,7 +247,9 @@ class JobService(BaseService):
bytes_in = bytes_out = items = 0
durations = []
for row in rows:
by_status[row.get("status", "")] = by_status.get(row.get("status", ""), 0) + 1
by_status[row.get("status", "")] = (
by_status.get(row.get("status", ""), 0) + 1
)
bytes_in += int(row.get("bytes_in") or 0)
bytes_out += int(row.get("bytes_out") or 0)
items += int(row.get("item_count") or 0)
+30 -14
View File
@@ -45,20 +45,32 @@ class ForkService(JobService):
if not title:
raise ValueError("a destination title is required")
new_uid, new_slug = create_content_item("projects", "project", user, {
"title": title,
"description": source.get("description") or "",
"release_date": source.get("release_date") or None,
"demo_date": source.get("demo_date") or None,
"project_type": source.get("project_type") or "software",
"platforms": source.get("platforms") or "",
"status": source.get("status") or "In Development",
"is_private": 1 if source.get("is_private") else 0,
"read_only": 0,
}, title, XP_PROJECT, "First Project", source.get("description") or "", None)
new_uid, new_slug = create_content_item(
"projects",
"project",
user,
{
"title": title,
"description": source.get("description") or "",
"release_date": source.get("release_date") or None,
"demo_date": source.get("demo_date") or None,
"project_type": source.get("project_type") or "software",
"platforms": source.get("platforms") or "",
"status": source.get("status") or "In Development",
"is_private": 1 if source.get("is_private") else 0,
"read_only": 0,
},
title,
XP_PROJECT,
"First Project",
source.get("description") or "",
None,
)
try:
item_count = await asyncio.to_thread(self._copy_files, source_uid, new_uid, user, job["uid"])
item_count = await asyncio.to_thread(
self._copy_files, source_uid, new_uid, user, job["uid"]
)
record_fork(source_uid, new_uid, forked_by_uid)
except Exception:
self._rollback(new_uid)
@@ -71,11 +83,15 @@ class ForkService(JobService):
"item_count": item_count,
}
def _copy_files(self, source_uid: str, new_uid: str, user: dict, job_uid: str) -> int:
def _copy_files(
self, source_uid: str, new_uid: str, user: dict, job_uid: str
) -> int:
staging = STAGING_DIR / job_uid
try:
project_files.export_to_dir(source_uid, "", staging)
return project_files.import_from_dir(new_uid, staging, user, skip_names=set())
return project_files.import_from_dir(
new_uid, staging, user, skip_names=set()
)
finally:
shutil.rmtree(staging, ignore_errors=True)
+36 -29
View File
@@ -39,31 +39,35 @@ def _hydrate(row: dict) -> dict:
return row
def enqueue(kind: str, payload: dict, owner_kind: str, owner_id: str, preferred_name: str = "") -> str:
def enqueue(
kind: str, payload: dict, owner_kind: str, owner_id: str, preferred_name: str = ""
) -> str:
uid = generate_uid()
now = _now()
_table().insert({
"uid": uid,
"kind": kind,
"status": PENDING,
"owner_kind": owner_kind,
"owner_id": owner_id,
"preferred_name": preferred_name,
"payload": json.dumps(payload),
"result": "",
"error": "",
"retry_count": 0,
"created_at": now,
"started_at": "",
"completed_at": "",
"updated_at": now,
"duration_ms": 0,
"last_accessed_at": "",
"expires_at": "",
"bytes_in": 0,
"bytes_out": 0,
"item_count": 0,
})
_table().insert(
{
"uid": uid,
"kind": kind,
"status": PENDING,
"owner_kind": owner_kind,
"owner_id": owner_id,
"preferred_name": preferred_name,
"payload": json.dumps(payload),
"result": "",
"error": "",
"retry_count": 0,
"created_at": now,
"started_at": "",
"completed_at": "",
"updated_at": now,
"duration_ms": 0,
"last_accessed_at": "",
"expires_at": "",
"bytes_in": 0,
"bytes_out": 0,
"item_count": 0,
}
)
logger.info("Enqueued %s job %s for %s/%s", kind, uid, owner_kind, owner_id)
return uid
@@ -82,12 +86,15 @@ def touch_job(uid: str, extend_seconds: int) -> None:
if not row:
return
now = datetime.now(timezone.utc)
_table().update({
"uid": uid,
"last_accessed_at": now.isoformat(),
"expires_at": (now + timedelta(seconds=extend_seconds)).isoformat(),
"updated_at": now.isoformat(),
}, ["uid"])
_table().update(
{
"uid": uid,
"last_accessed_at": now.isoformat(),
"expires_at": (now + timedelta(seconds=extend_seconds)).isoformat(),
"updated_at": now.isoformat(),
},
["uid"],
)
def list_jobs(kind: str = None, status: str = None, owner: tuple = None) -> list[dict]:
+11 -3
View File
@@ -66,19 +66,27 @@ class ZipService(JobService):
def _materialize(self, source: dict, staging: Path) -> int:
if source.get("type") == "project_tree":
return project_files.export_to_dir(source["project_uid"], source.get("path", ""), staging)
return project_files.export_to_dir(
source["project_uid"], source.get("path", ""), staging
)
raise ValueError(f"unsupported zip source: {source.get('type')}")
async def _run_worker(self, source_dir: Path, output_path: Path) -> dict:
proc = await asyncio.create_subprocess_exec(
sys.executable, "-m", WORKER_MODULE, str(source_dir), str(output_path),
sys.executable,
"-m",
WORKER_MODULE,
str(source_dir),
str(output_path),
cwd=str(BASE_DIR),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"zip worker exited {proc.returncode}: {err.decode('utf-8', 'replace')[:500]}")
raise RuntimeError(
f"zip worker exited {proc.returncode}: {err.decode('utf-8', 'replace')[:500]}"
)
return json.loads(out.decode("utf-8"))
def _final_name(self, preferred_name: str, crc32: int) -> str:
+129 -66
View File
@@ -51,7 +51,9 @@ async def _get_article_images(url: str, client: httpx.AsyncClient) -> list[dict]
if src in seen:
continue
seen.add(src)
if src.startswith("http") and not any(ext in src.lower() for ext in [".svg", ".ico"]):
if src.startswith("http") and not any(
ext in src.lower() for ext in [".svg", ".ico"]
):
images.append({"url": src, "alt_text": ""})
return images[:10]
except Exception:
@@ -68,19 +70,49 @@ class NewsService(BaseService):
"at or above the grade threshold and saving the rest as drafts."
)
config_fields = [
ConfigField("news_api_url", "News API URL", type="url", default=NEWS_API_URL_DEFAULT,
help="Source feed endpoint articles are fetched from.", group="Source"),
ConfigField("news_ai_url", "AI grading URL", type="url", default=AI_URL_DEFAULT,
help="Chat-completions endpoint used to grade each article.", group="AI grading"),
ConfigField("news_ai_model", "AI model", type="str", default=AI_MODEL_DEFAULT,
help="Model name sent to the grading endpoint.", group="AI grading"),
ConfigField("news_grade_threshold", "Grade threshold (1-10)", type="int",
default=GRADE_THRESHOLD_DEFAULT, minimum=1, maximum=10,
help="Articles graded at or above this are auto-published; below go to draft.",
group="AI grading"),
ConfigField("news_ai_key", "AI API key", type="password", default="", secret=True,
help="Defaults to the NEWS_AI_KEY env var, then the gateway's internal key.",
group="AI grading"),
ConfigField(
"news_api_url",
"News API URL",
type="url",
default=NEWS_API_URL_DEFAULT,
help="Source feed endpoint articles are fetched from.",
group="Source",
),
ConfigField(
"news_ai_url",
"AI grading URL",
type="url",
default=AI_URL_DEFAULT,
help="Chat-completions endpoint used to grade each article.",
group="AI grading",
),
ConfigField(
"news_ai_model",
"AI model",
type="str",
default=AI_MODEL_DEFAULT,
help="Model name sent to the grading endpoint.",
group="AI grading",
),
ConfigField(
"news_grade_threshold",
"Grade threshold (1-10)",
type="int",
default=GRADE_THRESHOLD_DEFAULT,
minimum=1,
maximum=10,
help="Articles graded at or above this are auto-published; below go to draft.",
group="AI grading",
),
ConfigField(
"news_ai_key",
"AI API key",
type="password",
default="",
secret=True,
help="Defaults to the NEWS_AI_KEY env var, then the gateway's internal key.",
group="AI grading",
),
]
def __init__(self):
@@ -152,58 +184,81 @@ class NewsService(BaseService):
if existing:
existing_slug = existing.get("slug", "")
news_table.update({
"id": existing["id"],
"grade": grade_val,
"status": is_published,
"title": article.get("title", ""),
"slug": existing_slug or make_combined_slug(article.get("title", "") or "news", existing["uid"]),
"description": strip_html(article.get("description", "") or "")[:5000],
"url": article.get("link", ""),
"source_name": article.get("feed_name", ""),
"content": strip_html(article.get("content", "") or "")[:10000],
"author": article.get("author", ""),
"article_published": article.get("published", ""),
"synced_at": now,
}, ["id"])
news_table.update(
{
"id": existing["id"],
"grade": grade_val,
"status": is_published,
"title": article.get("title", ""),
"slug": existing_slug
or make_combined_slug(
article.get("title", "") or "news", existing["uid"]
),
"description": strip_html(
article.get("description", "") or ""
)[:5000],
"url": article.get("link", ""),
"source_name": article.get("feed_name", ""),
"content": strip_html(article.get("content", "") or "")[
:10000
],
"author": article.get("author", ""),
"article_published": article.get("published", ""),
"synced_at": now,
},
["id"],
)
updated_count += 1
images_table.delete(news_uid=existing["uid"])
article_uid = existing["uid"]
else:
article_uid = generate_uid()
article_slug = make_combined_slug(article.get("title", "") or "news", article_uid)
news_table.insert({
"uid": article_uid,
"slug": article_slug,
"external_id": external_id,
"title": article.get("title", ""),
"description": strip_html(article.get("description", "") or "")[:5000],
"url": article.get("link", ""),
"image_url": "",
"source_name": article.get("feed_name", ""),
"grade": grade_val,
"status": is_published,
"content": strip_html(article.get("content", "") or "")[:10000],
"author": article.get("author", ""),
"article_published": article.get("published", ""),
"synced_at": now,
})
article_slug = make_combined_slug(
article.get("title", "") or "news", article_uid
)
news_table.insert(
{
"uid": article_uid,
"slug": article_slug,
"external_id": external_id,
"title": article.get("title", ""),
"description": strip_html(
article.get("description", "") or ""
)[:5000],
"url": article.get("link", ""),
"image_url": "",
"source_name": article.get("feed_name", ""),
"grade": grade_val,
"status": is_published,
"content": strip_html(article.get("content", "") or "")[
:10000
],
"author": article.get("author", ""),
"article_published": article.get("published", ""),
"synced_at": now,
}
)
new_count += 1
existing_sync = sync_table.find_one(external_id=external_id)
if existing_sync:
sync_table.update({
"id": existing_sync["id"],
"status": sync_status,
"synced_at": now,
}, ["id"])
sync_table.update(
{
"id": existing_sync["id"],
"status": sync_status,
"synced_at": now,
},
["id"],
)
else:
sync_table.insert({
"uid": generate_uid(),
"external_id": external_id,
"status": sync_status,
"synced_at": now,
})
sync_table.insert(
{
"uid": generate_uid(),
"external_id": external_id,
"status": sync_status,
"synced_at": now,
}
)
synced_ids.add(external_id)
@@ -211,17 +266,23 @@ class NewsService(BaseService):
if link:
fresh_images = await _get_article_images(link, client)
for img in fresh_images:
images_table.insert({
"uid": generate_uid(),
"news_uid": article_uid,
"url": img["url"],
"alt_text": img.get("alt_text", ""),
})
images_table.insert(
{
"uid": generate_uid(),
"news_uid": article_uid,
"url": img["url"],
"alt_text": img.get("alt_text", ""),
}
)
self.log(f"New {new_count}, updated {updated_count}, draft {draft_count}, "
f"grading failed {failed_count}, skipped {skipped_count}")
self.log(
f"New {new_count}, updated {updated_count}, draft {draft_count}, "
f"grading failed {failed_count}, skipped {skipped_count}"
)
async def _grade_article(self, article: dict, ai_url: str, ai_model: str, client: httpx.AsyncClient) -> int | None:
async def _grade_article(
self, article: dict, ai_url: str, ai_model: str, client: httpx.AsyncClient
) -> int | None:
title = (article.get("title", "") or "")[:500]
description = strip_html(article.get("description", "") or "")[:1000]
content = strip_html(article.get("content", "") or "")[:1500]
@@ -283,7 +344,9 @@ class NewsService(BaseService):
headers["Authorization"] = f"Bearer {ai_key}"
try:
resp = await client.post(ai_url, json=payload, headers=headers, timeout=15.0)
resp = await client.post(
ai_url, json=payload, headers=headers, timeout=15.0
)
if resp.status_code != 200:
self.log(f"AI grading returned {resp.status_code}: {resp.text[:200]}")
resp.raise_for_status()
+222 -81
View File
@@ -30,7 +30,15 @@ def _iso(moment: datetime) -> str:
def _pset(values: list[float]) -> dict:
if not values:
return {"avg": 0.0, "p50": 0.0, "p90": 0.0, "p95": 0.0, "p99": 0.0, "max": 0.0, "count": 0}
return {
"avg": 0.0,
"p50": 0.0,
"p90": 0.0,
"p95": 0.0,
"p99": 0.0,
"max": 0.0,
"count": 0,
}
ordered = sorted(values)
return {
"avg": round(sum(ordered) / len(ordered), 3),
@@ -51,8 +59,16 @@ def _top_group(rows: list[dict], key_fn, top_n: int) -> list[dict]:
agg: dict = {}
for r in rows:
key = key_fn(r)
bucket = agg.setdefault(key, {"key": key, "requests": 0, "success": 0,
"cost_usd": 0.0, "total_tokens": 0})
bucket = agg.setdefault(
key,
{
"key": key,
"requests": 0,
"success": 0,
"cost_usd": 0.0,
"total_tokens": 0,
},
)
bucket["requests"] += 1
bucket["success"] += int(r.get("success") or 0)
bucket["cost_usd"] += float(r.get("cost_usd") or 0)
@@ -64,19 +80,24 @@ def _top_group(rows: list[dict], key_fn, top_n: int) -> list[dict]:
def _ledger_rows(cutoff: str) -> list[dict]:
return list(db.query(
f"SELECT * FROM {GATEWAY_LEDGER} WHERE created_at >= :cutoff ORDER BY created_at",
cutoff=cutoff,
))
return list(
db.query(
f"SELECT * FROM {GATEWAY_LEDGER} WHERE created_at >= :cutoff ORDER BY created_at",
cutoff=cutoff,
)
)
def _concurrency(cutoff: str) -> dict:
if GATEWAY_CONCURRENCY not in db.tables:
return {"peak": 0, "avg": 0.0, "p95": 0.0, "samples": 0}
values = [int(r["in_flight"] or 0) for r in db.query(
f"SELECT in_flight FROM {GATEWAY_CONCURRENCY} WHERE created_at >= :cutoff",
cutoff=cutoff,
)]
values = [
int(r["in_flight"] or 0)
for r in db.query(
f"SELECT in_flight FROM {GATEWAY_CONCURRENCY} WHERE created_at >= :cutoff",
cutoff=cutoff,
)
]
if not values:
return {"peak": 0, "avg": 0.0, "p95": 0.0, "samples": 0}
ordered = sorted(values)
@@ -93,13 +114,20 @@ def empty_payload(hours: int = 48) -> dict:
"window_hours": hours,
"generated_at": _iso(_now()),
"requests": 0,
"volume": {}, "tokens": {}, "latency": {}, "errors": {}, "cost": {}, "behavior": {},
"volume": {},
"tokens": {},
"latency": {},
"errors": {},
"cost": {},
"behavior": {},
"hourly": [],
"notes": {"ttft": "not available: gateway forwards non-streaming upstream"},
}
def build_analytics(hours: int = 48, top_n: int = 10, pricing: Optional[Pricing] = None) -> dict:
def build_analytics(
hours: int = 48, top_n: int = 10, pricing: Optional[Pricing] = None
) -> dict:
if GATEWAY_LEDGER not in db.tables:
return empty_payload(hours)
hours = max(1, min(hours, MAX_WINDOW_HOURS))
@@ -118,7 +146,9 @@ def build_analytics(hours: int = 48, top_n: int = 10, pricing: Optional[Pricing]
minute_counts: dict = {}
for r in rows:
minute_counts[r["created_at"][:16]] = minute_counts.get(r["created_at"][:16], 0) + 1
minute_counts[r["created_at"][:16]] = (
minute_counts.get(r["created_at"][:16], 0) + 1
)
peak_req_per_min = max(minute_counts.values()) if minute_counts else 0
first_hour: dict = {}
@@ -131,8 +161,14 @@ def build_analytics(hours: int = 48, top_n: int = 10, pricing: Optional[Pricing]
total_cost = sum(float(r.get("cost_usd") or 0) for r in rows)
input_cost = sum(float(r.get("input_cost_usd") or 0) for r in rows)
output_cost = sum(float(r.get("output_cost_usd") or 0) for r in rows)
cost_this_hour = sum(float(r.get("cost_usd") or 0) for r in rows if r["created_at"][:13] == hour_start)
cost_24h = sum(float(r.get("cost_usd") or 0) for r in rows if r["created_at"] >= day_cutoff)
cost_this_hour = sum(
float(r.get("cost_usd") or 0)
for r in rows
if r["created_at"][:13] == hour_start
)
cost_24h = sum(
float(r.get("cost_usd") or 0) for r in rows if r["created_at"] >= day_cutoff
)
prompt_total = sum(int(r.get("prompt_tokens") or 0) for r in rows)
completion_total = sum(int(r.get("completion_tokens") or 0) for r in rows)
@@ -144,10 +180,18 @@ def build_analytics(hours: int = 48, top_n: int = 10, pricing: Optional[Pricing]
caching_savings = 0.0
if pricing is not None:
rate_delta = pricing.chat_cache_miss_per_m - pricing.chat_cache_hit_per_m
chat_cache_hits = sum(int(r.get("cache_hit_tokens") or 0) for r in rows if r.get("backend") == "chat")
chat_cache_hits = sum(
int(r.get("cache_hit_tokens") or 0)
for r in rows
if r.get("backend") == "chat"
)
caching_savings = chat_cache_hits / PER_MILLION * rate_delta
util_values = [float(r["context_utilization"]) for r in rows if r.get("context_utilization") is not None]
util_values = [
float(r["context_utilization"])
for r in rows
if r.get("context_utilization") is not None
]
owner_counts: dict = {}
for r in rows:
@@ -161,7 +205,8 @@ def build_analytics(hours: int = 48, top_n: int = 10, pricing: Optional[Pricing]
ua_counts[ua] = ua_counts.get(ua, 0) + 1
user_agents = sorted(
[{"key": k, "requests": v} for k, v in ua_counts.items()],
key=lambda x: x["requests"], reverse=True,
key=lambda x: x["requests"],
reverse=True,
)[:top_n]
error_categories: dict = {}
@@ -172,7 +217,9 @@ def build_analytics(hours: int = 48, top_n: int = 10, pricing: Optional[Pricing]
count_4xx = sum(1 for r in rows if 400 <= int(r.get("status_code") or 0) < 500)
count_5xx = sum(1 for r in rows if int(r.get("status_code") or 0) >= 500)
temperatures = [float(r["temperature"]) for r in rows if r.get("temperature") is not None]
temperatures = [
float(r["temperature"]) for r in rows if r.get("temperature") is not None
]
top_ps = [float(r["top_p"]) for r in rows if r.get("top_p") is not None]
hourly = _hourly(rows, first_hour)
@@ -184,9 +231,13 @@ def build_analytics(hours: int = 48, top_n: int = 10, pricing: Optional[Pricing]
"requests_per_hour": round(requests / hours, 2),
"peak_requests_per_minute": peak_req_per_min,
"by_model": _top_group(rows, lambda r: r.get("model") or "unknown", top_n),
"by_endpoint": _top_group(rows, lambda r: r.get("endpoint") or "unknown", top_n),
"by_endpoint": _top_group(
rows, lambda r: r.get("endpoint") or "unknown", top_n
),
"by_backend": _top_group(rows, lambda r: r.get("backend") or "unknown", 0),
"by_caller": _top_group(rows, lambda r: f"{r.get('owner_kind')}:{r.get('owner_id')}", top_n),
"by_caller": _top_group(
rows, lambda r: f"{r.get('owner_kind')}:{r.get('owner_id')}", top_n
),
"concurrency": _concurrency(cutoff),
}
@@ -197,17 +248,27 @@ def build_analytics(hours: int = 48, top_n: int = 10, pricing: Optional[Pricing]
"cache_hit_total": cache_hit_total,
"cache_miss_total": cache_miss_total,
"reasoning_total": reasoning_total,
"cache_hit_rate": round(cache_hit_total / prompt_total, 4) if prompt_total else 0.0,
"input_output_ratio": round(prompt_total / completion_total, 3) if completion_total else 0.0,
"cache_hit_rate": round(cache_hit_total / prompt_total, 4)
if prompt_total
else 0.0,
"input_output_ratio": round(prompt_total / completion_total, 3)
if completion_total
else 0.0,
"tokens_per_hour": round(total_tokens / hours, 1),
"context_utilization_avg": round(sum(util_values) / len(util_values), 4) if util_values else None,
"context_utilization_avg": round(sum(util_values) / len(util_values), 4)
if util_values
else None,
"prompt": _pset(_positive(rows, "prompt_tokens")),
"completion": _pset(_positive(rows, "completion_tokens")),
"total_per_request": _pset(_positive(rows, "total_tokens")),
}
avg_upstream = sum(_positive(rows, "upstream_latency_ms")) / max(len(_positive(rows, "upstream_latency_ms")), 1)
avg_total = sum(_positive(rows, "total_latency_ms")) / max(len(_positive(rows, "total_latency_ms")), 1)
avg_upstream = sum(_positive(rows, "upstream_latency_ms")) / max(
len(_positive(rows, "upstream_latency_ms")), 1
)
avg_total = sum(_positive(rows, "total_latency_ms")) / max(
len(_positive(rows, "total_latency_ms")), 1
)
latency = {
"upstream_ms": _pset(_positive(rows, "upstream_latency_ms")),
"gateway_overhead_ms": _pset(_positive(rows, "gateway_overhead_ms")),
@@ -215,8 +276,12 @@ def build_analytics(hours: int = 48, top_n: int = 10, pricing: Optional[Pricing]
"connect_ms": _pset(_positive(rows, "connect_ms")),
"total_ms": _pset(_positive(rows, "total_latency_ms")),
"tokens_per_second": _pset(_positive(rows, "tokens_per_second")),
"gateway_overhead_share": round((avg_total - avg_upstream) / avg_total, 4) if avg_total else 0.0,
"upstream_availability_pct": round(success / requests * 100, 2) if requests else 0.0,
"gateway_overhead_share": round((avg_total - avg_upstream) / avg_total, 4)
if avg_total
else 0.0,
"upstream_availability_pct": round(success / requests * 100, 2)
if requests
else 0.0,
"ttft_ms": None,
"inter_token_ms": None,
}
@@ -240,10 +305,20 @@ def build_analytics(hours: int = 48, top_n: int = 10, pricing: Optional[Pricing]
"this_hour_usd": round(cost_this_hour, 6),
"last_24h_usd": round(cost_24h, 6),
"projected_monthly_usd": round(cost_24h * 30, 2),
"effective_per_1k_tokens_usd": round(total_cost / total_tokens * 1000, 6) if total_tokens else 0.0,
"effective_per_1k_tokens_usd": round(total_cost / total_tokens * 1000, 6)
if total_tokens
else 0.0,
"caching_savings_usd": round(caching_savings, 6),
"by_model": [{"key": b["key"], "cost_usd": b["cost_usd"]} for b in _top_group(rows, lambda r: r.get("model") or "unknown", top_n)],
"by_caller": [{"key": b["key"], "cost_usd": b["cost_usd"]} for b in _top_group(rows, lambda r: f"{r.get('owner_kind')}:{r.get('owner_id')}", top_n)],
"by_model": [
{"key": b["key"], "cost_usd": b["cost_usd"]}
for b in _top_group(rows, lambda r: r.get("model") or "unknown", top_n)
],
"by_caller": [
{"key": b["key"], "cost_usd": b["cost_usd"]}
for b in _top_group(
rows, lambda r: f"{r.get('owner_kind')}:{r.get('owner_id')}", top_n
)
],
}
behavior = {
@@ -253,11 +328,14 @@ def build_analytics(hours: int = 48, top_n: int = 10, pricing: Optional[Pricing]
"p95": round(percentile(per_owner, 0.95), 2) if per_owner else 0.0,
"max": per_owner[-1] if per_owner else 0,
},
"avg_temperature": round(sum(temperatures) / len(temperatures), 3) if temperatures else None,
"avg_temperature": round(sum(temperatures) / len(temperatures), 3)
if temperatures
else None,
"avg_top_p": round(sum(top_ps) / len(top_ps), 3) if top_ps else None,
"tool_call_requests": sum(int(r.get("has_tools") or 0) for r in rows),
"streaming_requests": sum(int(r.get("stream_requested") or 0) for r in rows),
"non_streaming_requests": requests - sum(int(r.get("stream_requested") or 0) for r in rows),
"non_streaming_requests": requests
- sum(int(r.get("stream_requested") or 0) for r in rows),
"user_agents": user_agents,
}
@@ -281,12 +359,23 @@ def _hourly(rows: list[dict], first_hour: dict) -> list[dict]:
for r in rows:
hour = r["created_at"][:13]
owner = f"{r.get('owner_kind')}:{r.get('owner_id')}"
bucket = buckets.setdefault(hour, {
"hour": hour, "requests": 0, "success": 0, "failed": 0,
"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0,
"cost_usd": 0.0, "latency_sum": 0.0, "latency_n": 0,
"owners": set(), "new_owners": 0,
})
bucket = buckets.setdefault(
hour,
{
"hour": hour,
"requests": 0,
"success": 0,
"failed": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"cost_usd": 0.0,
"latency_sum": 0.0,
"latency_n": 0,
"owners": set(),
"new_owners": 0,
},
)
bucket["requests"] += 1
if int(r.get("success") or 0):
bucket["success"] += 1
@@ -303,10 +392,16 @@ def _hourly(rows: list[dict], first_hour: dict) -> list[dict]:
bucket["owners"].add(owner)
for hour, bucket in buckets.items():
bucket["new_owners"] = sum(1 for owner in bucket["owners"] if first_hour.get(owner) == hour)
bucket["new_owners"] = sum(
1 for owner in bucket["owners"] if first_hour.get(owner) == hour
)
bucket["active_owners"] = len(bucket["owners"])
bucket["returning_owners"] = bucket["active_owners"] - bucket["new_owners"]
bucket["avg_latency_ms"] = round(bucket["latency_sum"] / bucket["latency_n"], 1) if bucket["latency_n"] else 0.0
bucket["avg_latency_ms"] = (
round(bucket["latency_sum"] / bucket["latency_n"], 1)
if bucket["latency_n"]
else 0.0
)
bucket["cost_usd"] = round(bucket["cost_usd"], 6)
del bucket["owners"], bucket["latency_sum"], bucket["latency_n"]
@@ -324,14 +419,21 @@ def empty_user_usage(owner_id: str, hours: int = 24) -> dict:
"success_pct": 0.0,
"error_pct": 0.0,
"tokens": {"prompt": 0, "completion": 0, "total": 0},
"cost": {"window_usd": 0.0, "per_hour_usd": 0.0, "per_request_usd": 0.0, "projected_30d_usd": 0.0},
"cost": {
"window_usd": 0.0,
"per_hour_usd": 0.0,
"per_request_usd": 0.0,
"projected_30d_usd": 0.0,
},
"latency": {"avg_ms": 0.0, "avg_tps": 0.0},
"first_used": None,
"last_used": None,
"by_model": [],
"by_backend": [],
"hourly": [],
"notes": {"projection": "30-day projection extrapolates the full 24h spend (24h cost x 30)"},
"notes": {
"projection": "30-day projection extrapolates the full 24h spend (24h cost x 30)"
},
}
@@ -339,7 +441,9 @@ def _user_hourly(rows: list[dict]) -> list[dict]:
buckets: dict = {}
for r in rows:
hour = r["created_at"][:13]
bucket = buckets.setdefault(hour, {"hour": hour, "requests": 0, "cost_usd": 0.0, "total_tokens": 0})
bucket = buckets.setdefault(
hour, {"hour": hour, "requests": 0, "cost_usd": 0.0, "total_tokens": 0}
)
bucket["requests"] += 1
bucket["cost_usd"] += float(r.get("cost_usd") or 0)
bucket["total_tokens"] += int(r.get("total_tokens") or 0)
@@ -349,16 +453,21 @@ def _user_hourly(rows: list[dict]) -> list[dict]:
return out
def build_user_usage(owner_id: str, hours: int = 24, pricing: Optional[Pricing] = None) -> dict:
def build_user_usage(
owner_id: str, hours: int = 24, pricing: Optional[Pricing] = None
) -> dict:
hours = max(1, min(hours, MAX_WINDOW_HOURS))
if not owner_id or GATEWAY_LEDGER not in db.tables:
return empty_user_usage(owner_id, hours)
now = _now()
cutoff = _iso(now - timedelta(hours=hours))
rows = list(db.query(
f"SELECT * FROM {GATEWAY_LEDGER} WHERE owner_id = :oid AND created_at >= :cutoff ORDER BY created_at",
oid=owner_id, cutoff=cutoff,
))
rows = list(
db.query(
f"SELECT * FROM {GATEWAY_LEDGER} WHERE owner_id = :oid AND created_at >= :cutoff ORDER BY created_at",
oid=owner_id,
cutoff=cutoff,
)
)
if not rows:
return empty_user_usage(owner_id, hours)
@@ -385,7 +494,11 @@ def build_user_usage(owner_id: str, hours: int = 24, pricing: Optional[Pricing]
"failed": failed,
"success_pct": round(success / requests * 100, 1) if requests else 0.0,
"error_pct": round(failed / requests * 100, 1) if requests else 0.0,
"tokens": {"prompt": prompt_total, "completion": completion_total, "total": total_tokens},
"tokens": {
"prompt": prompt_total,
"completion": completion_total,
"total": total_tokens,
},
"cost": {
"window_usd": round(total_cost, 6),
"per_hour_usd": round(cost_per_hour, 6),
@@ -398,50 +511,74 @@ def build_user_usage(owner_id: str, hours: int = 24, pricing: Optional[Pricing]
"by_model": _top_group(rows, lambda r: r.get("model") or "unknown", 0),
"by_backend": _top_group(rows, lambda r: r.get("backend") or "unknown", 0),
"hourly": _user_hourly(rows),
"notes": {"projection": "30-day projection extrapolates the full 24h spend (24h cost x 30)"},
"notes": {
"projection": "30-day projection extrapolates the full 24h spend (24h cost x 30)"
},
}
def summary_metrics() -> dict:
zero = {"requests": 0, "success_pct": 0.0, "error_pct": 0.0, "cost_hour": 0.0,
"cost_24h": 0.0, "tokens_24h": 0, "avg_latency_ms": 0.0, "avg_tps": 0.0,
"peak_concurrency": 0, "top_model": "-", "top_caller": "-"}
zero = {
"requests": 0,
"success_pct": 0.0,
"error_pct": 0.0,
"cost_hour": 0.0,
"cost_24h": 0.0,
"tokens_24h": 0,
"avg_latency_ms": 0.0,
"avg_tps": 0.0,
"peak_concurrency": 0,
"top_model": "-",
"top_caller": "-",
}
if GATEWAY_LEDGER not in db.tables:
return zero
now = _now()
cutoff = _iso(now - timedelta(hours=24))
hour_start = now.strftime("%Y-%m-%dT%H")
agg = list(db.query(
f"SELECT COUNT(*) AS requests, COALESCE(SUM(success),0) AS ok, "
f"COALESCE(SUM(cost_usd),0) AS cost, COALESCE(SUM(total_tokens),0) AS tokens, "
f"COALESCE(AVG(upstream_latency_ms),0) AS avg_lat, "
f"COALESCE(AVG(NULLIF(tokens_per_second,0)),0) AS avg_tps "
f"FROM {GATEWAY_LEDGER} WHERE created_at >= :cutoff",
cutoff=cutoff,
))
agg = list(
db.query(
f"SELECT COUNT(*) AS requests, COALESCE(SUM(success),0) AS ok, "
f"COALESCE(SUM(cost_usd),0) AS cost, COALESCE(SUM(total_tokens),0) AS tokens, "
f"COALESCE(AVG(upstream_latency_ms),0) AS avg_lat, "
f"COALESCE(AVG(NULLIF(tokens_per_second,0)),0) AS avg_tps "
f"FROM {GATEWAY_LEDGER} WHERE created_at >= :cutoff",
cutoff=cutoff,
)
)
row = agg[0] if agg else {}
requests = int(row.get("requests") or 0)
if not requests:
return zero
ok = int(row.get("ok") or 0)
hour_rows = list(db.query(
f"SELECT COALESCE(SUM(cost_usd),0) AS c FROM {GATEWAY_LEDGER} WHERE created_at >= :h",
h=hour_start,
))
top_model = list(db.query(
f"SELECT model, COUNT(*) AS n FROM {GATEWAY_LEDGER} WHERE created_at >= :cutoff "
f"GROUP BY model ORDER BY n DESC LIMIT 1", cutoff=cutoff,
))
top_caller = list(db.query(
f"SELECT owner_kind, owner_id, COUNT(*) AS n FROM {GATEWAY_LEDGER} WHERE created_at >= :cutoff "
f"GROUP BY owner_kind, owner_id ORDER BY n DESC LIMIT 1", cutoff=cutoff,
))
hour_rows = list(
db.query(
f"SELECT COALESCE(SUM(cost_usd),0) AS c FROM {GATEWAY_LEDGER} WHERE created_at >= :h",
h=hour_start,
)
)
top_model = list(
db.query(
f"SELECT model, COUNT(*) AS n FROM {GATEWAY_LEDGER} WHERE created_at >= :cutoff "
f"GROUP BY model ORDER BY n DESC LIMIT 1",
cutoff=cutoff,
)
)
top_caller = list(
db.query(
f"SELECT owner_kind, owner_id, COUNT(*) AS n FROM {GATEWAY_LEDGER} WHERE created_at >= :cutoff "
f"GROUP BY owner_kind, owner_id ORDER BY n DESC LIMIT 1",
cutoff=cutoff,
)
)
peak = 0
if GATEWAY_CONCURRENCY in db.tables:
peak_rows = list(db.query(
f"SELECT COALESCE(MAX(in_flight),0) AS m FROM {GATEWAY_CONCURRENCY} WHERE created_at >= :cutoff",
cutoff=cutoff,
))
peak_rows = list(
db.query(
f"SELECT COALESCE(MAX(in_flight),0) AS m FROM {GATEWAY_CONCURRENCY} WHERE created_at >= :cutoff",
cutoff=cutoff,
)
)
peak = int(peak_rows[0]["m"]) if peak_rows else 0
return {
"requests": requests,
@@ -453,6 +590,10 @@ def summary_metrics() -> dict:
"avg_latency_ms": round(float(row.get("avg_lat") or 0), 1),
"avg_tps": round(float(row.get("avg_tps") or 0), 2),
"peak_concurrency": peak,
"top_model": top_model[0]["model"] if top_model and top_model[0].get("model") else "-",
"top_caller": f"{top_caller[0]['owner_kind']}:{top_caller[0]['owner_id']}" if top_caller else "-",
"top_model": top_model[0]["model"]
if top_model and top_model[0].get("model")
else "-",
"top_caller": f"{top_caller[0]['owner_kind']}:{top_caller[0]['owner_id']}"
if top_caller
else "-",
}
+207 -55
View File
@@ -36,24 +36,30 @@ def _fake_stream(data: dict, model: str):
reasoning_content = msg.get("reasoning_content") or ""
def _chunk(delta: dict, finish: Optional[str] = None) -> str:
return "data: " + json.dumps({
"id": chunk_id,
"object": "chat.completion.chunk",
"created": created,
"model": out_model,
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
}) + "\n\n"
return (
"data: "
+ json.dumps(
{
"id": chunk_id,
"object": "chat.completion.chunk",
"created": created,
"model": out_model,
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
}
)
+ "\n\n"
)
async def gen():
yield _chunk({"role": "assistant"})
if reasoning_content:
for i in range(0, len(reasoning_content), 50):
yield _chunk({"reasoning_content": reasoning_content[i:i + 50]})
yield _chunk({"reasoning_content": reasoning_content[i : i + 50]})
if tool_calls:
yield _chunk({"tool_calls": tool_calls})
elif content:
for i in range(0, len(content), 50):
yield _chunk({"content": content[i:i + 50]})
yield _chunk({"content": content[i : i + 50]})
yield _chunk({}, finish="tool_calls" if tool_calls else "stop")
yield "data: [DONE]\n\n"
@@ -66,7 +72,9 @@ def _connect_tracer(holder: dict):
async def trace(name: str, info: dict) -> None:
if name.endswith("connect_tcp.started") or name.endswith("start_tls.started"):
started[name] = time.monotonic()
elif name.endswith("connect_tcp.complete") or name.endswith("start_tls.complete"):
elif name.endswith("connect_tcp.complete") or name.endswith(
"start_tls.complete"
):
begin = started.get(name.replace(".complete", ".started"))
if begin is not None:
holder["ms"] += (time.monotonic() - begin) * 1000
@@ -83,7 +91,9 @@ class GatewayRuntime:
self._vision_cache: Optional[VisionCache] = None
self._vision_cache_size = -1
self._ledger = GatewayUsageLedger()
self._breaker = CircuitBreaker(config.CIRCUIT_THRESHOLD_DEFAULT, config.CIRCUIT_COOLDOWN_SECONDS_DEFAULT)
self._breaker = CircuitBreaker(
config.CIRCUIT_THRESHOLD_DEFAULT, config.CIRCUIT_COOLDOWN_SECONDS_DEFAULT
)
self.requests = 0
self.errors = 0
self.in_flight = 0
@@ -95,9 +105,15 @@ class GatewayRuntime:
def _ensure(self, cfg: dict):
instances = max(1, cfg["gateway_instances"])
timeout = max(1, cfg["gateway_timeout"])
if self._client is None or instances != self._instances or timeout != self._timeout:
if (
self._client is None
or instances != self._instances
or timeout != self._timeout
):
old = self._client
limits = httpx.Limits(max_connections=instances, max_keepalive_connections=instances)
limits = httpx.Limits(
max_connections=instances, max_keepalive_connections=instances
)
self._client = httpx.AsyncClient(timeout=float(timeout), limits=limits)
self._sem = asyncio.Semaphore(instances)
self._instances = instances
@@ -108,7 +124,9 @@ class GatewayRuntime:
if self._vision_cache is None or size != self._vision_cache_size:
self._vision_cache = VisionCache(size)
self._vision_cache_size = size
self._breaker.configure(cfg["gateway_circuit_threshold"], cfg["gateway_circuit_cooldown_seconds"])
self._breaker.configure(
cfg["gateway_circuit_threshold"], cfg["gateway_circuit_cooldown_seconds"]
)
return self._client, self._sem
async def aclose(self) -> None:
@@ -117,9 +135,17 @@ class GatewayRuntime:
self._client = None
self._instances = 0
async def _send(self, client, sem, method, url, headers, cfg, log, json_body=None, content=None):
timing = {"queue_wait_ms": 0.0, "upstream_latency_ms": 0.0, "connect_ms": 0.0,
"retries_attempted": 0, "retry_succeeded": False, "circuit_open": False}
async def _send(
self, client, sem, method, url, headers, cfg, log, json_body=None, content=None
):
timing = {
"queue_wait_ms": 0.0,
"upstream_latency_ms": 0.0,
"connect_ms": 0.0,
"retries_attempted": 0,
"retry_succeeded": False,
"circuit_open": False,
}
if not self._breaker.allow():
timing["circuit_open"] = True
log("circuit breaker open, rejecting upstream call")
@@ -135,18 +161,27 @@ class GatewayRuntime:
exc = None
try:
async with sem:
timing["queue_wait_ms"] = round((time.monotonic() - wait_start) * 1000, 3)
timing["queue_wait_ms"] = round(
(time.monotonic() - wait_start) * 1000, 3
)
async def do_call():
request = client.build_request(method, url, headers=headers,
json=json_body, content=content)
request = client.build_request(
method, url, headers=headers, json=json_body, content=content
)
request.extensions["trace"] = _connect_tracer(connect_holder)
return await client.send(request)
send_start = time.monotonic()
resp, exc, attempts = await retry_send(
do_call, cfg["gateway_max_retries"], cfg["gateway_retry_backoff_ms"], log)
timing["upstream_latency_ms"] = round((time.monotonic() - send_start) * 1000, 3)
do_call,
cfg["gateway_max_retries"],
cfg["gateway_retry_backoff_ms"],
log,
)
timing["upstream_latency_ms"] = round(
(time.monotonic() - send_start) * 1000, 3
)
finally:
self.in_flight -= 1
timing["connect_ms"] = round(connect_holder["ms"], 3)
@@ -166,7 +201,9 @@ class GatewayRuntime:
timing["retry_succeeded"] = attempts > 1
return resp, None, timing
async def handle_chat(self, body: dict, cfg: dict, owner: tuple, user_agent: str, log=None):
async def handle_chat(
self, body: dict, cfg: dict, owner: tuple, user_agent: str, log=None
):
log = log or (lambda message: None)
client, sem = self._ensure(cfg)
pricing = pricing_from_cfg(cfg)
@@ -177,8 +214,13 @@ class GatewayRuntime:
if cfg["gateway_vision_enabled"]:
augmenter = VisionAugmenter(
cfg["gateway_vision_url"], cfg["gateway_vision_model"], cfg["gateway_vision_key"],
self._vision_cache, ledger=self._ledger, owner=owner, pricing=pricing,
cfg["gateway_vision_url"],
cfg["gateway_vision_model"],
cfg["gateway_vision_key"],
self._vision_cache,
ledger=self._ledger,
owner=owner,
pricing=pricing,
context_map=context_map,
)
messages = await augmenter.augment_messages(client, messages)
@@ -200,21 +242,45 @@ class GatewayRuntime:
if cfg["gateway_api_key"]:
headers["Authorization"] = f"Bearer {cfg['gateway_api_key']}"
else:
log("No upstream API key configured (gateway_api_key / DEEPSEEK_API_KEY / OPENROUTER_API_KEY); upstream will likely reject the request")
log(
"No upstream API key configured (gateway_api_key / DEEPSEEK_API_KEY / OPENROUTER_API_KEY); upstream will likely reject the request"
)
resp, exc, timing = await self._send(
client, sem, "POST", cfg["gateway_upstream_url"], headers, cfg, log, json_body=payload)
client,
sem,
"POST",
cfg["gateway_upstream_url"],
headers,
cfg,
log,
json_body=payload,
)
base = {
"owner_kind": owner[0], "owner_id": owner[1], "backend": "chat",
"endpoint": "chat/completions", "model": model, "user_agent": user_agent,
**params, **timing,
"owner_kind": owner[0],
"owner_id": owner[1],
"backend": "chat",
"endpoint": "chat/completions",
"model": model,
"user_agent": user_agent,
**params,
**timing,
}
def finalize(status_code, success, category, usage=None):
base["total_latency_ms"] = round((time.monotonic() - handle_start) * 1000, 3)
base["gateway_overhead_ms"] = round(max(
base["total_latency_ms"] - timing["upstream_latency_ms"] - timing["queue_wait_ms"], 0.0), 3)
base["total_latency_ms"] = round(
(time.monotonic() - handle_start) * 1000, 3
)
base["gateway_overhead_ms"] = round(
max(
base["total_latency_ms"]
- timing["upstream_latency_ms"]
- timing["queue_wait_ms"],
0.0,
),
3,
)
base["status_code"] = status_code
base["success"] = success
base["error_category"] = category
@@ -223,29 +289,71 @@ class GatewayRuntime:
if timing["circuit_open"]:
finalize(503, False, "circuit_open")
return JSONResponse(status_code=503, content={"error": {"message": "Upstream temporarily unavailable", "type": "circuit_open"}})
return JSONResponse(
status_code=503,
content={
"error": {
"message": "Upstream temporarily unavailable",
"type": "circuit_open",
}
},
)
if exc is not None:
finalize(502, False, classify_error(0, exc))
return JSONResponse(status_code=502, content={"error": {"message": f"Upstream connection failed: {exc}", "type": "upstream_error"}})
return JSONResponse(
status_code=502,
content={
"error": {
"message": f"Upstream connection failed: {exc}",
"type": "upstream_error",
}
},
)
if resp.status_code != 200:
finalize(resp.status_code, False, classify_error(resp.status_code, None, resp.text))
finalize(
resp.status_code,
False,
classify_error(resp.status_code, None, resp.text),
)
log(f"chat upstream POST -> {resp.status_code}: {resp.text[:300]}")
return JSONResponse(status_code=resp.status_code, content={"error": {"message": resp.text, "type": "upstream_error"}})
return JSONResponse(
status_code=resp.status_code,
content={"error": {"message": resp.text, "type": "upstream_error"}},
)
try:
data = resp.json()
except ValueError:
self.errors += 1
finalize(502, False, "gateway")
log("chat upstream returned 200 but body was not valid JSON")
return JSONResponse(status_code=502, content={"error": {"message": "invalid upstream response", "type": "upstream_error"}})
return JSONResponse(
status_code=502,
content={
"error": {
"message": "invalid upstream response",
"type": "upstream_error",
}
},
)
finalize(200, True, None, data.get("usage"))
log(f"chat POST -> 200 ({timing['upstream_latency_ms']:.0f}ms)")
if stream:
return StreamingResponse(_fake_stream(data, model), media_type="text/event-stream")
return StreamingResponse(
_fake_stream(data, model), media_type="text/event-stream"
)
return JSONResponse(content=data)
async def handle_passthrough(self, method: str, subpath: str, content_type: str, body: bytes,
cfg: dict, owner: tuple, user_agent: str, log=None):
async def handle_passthrough(
self,
method: str,
subpath: str,
content_type: str,
body: bytes,
cfg: dict,
owner: tuple,
user_agent: str,
log=None,
):
log = log or (lambda message: None)
client, sem = self._ensure(cfg)
pricing = pricing_from_cfg(cfg)
@@ -261,18 +369,33 @@ class GatewayRuntime:
if content_type:
headers["Content-Type"] = content_type
resp, exc, timing = await self._send(client, sem, method, url, headers, cfg, log, content=body)
resp, exc, timing = await self._send(
client, sem, method, url, headers, cfg, log, content=body
)
base = {
"owner_kind": owner[0], "owner_id": owner[1], "backend": "chat",
"endpoint": subpath, "model": cfg["gateway_model"], "user_agent": user_agent,
"owner_kind": owner[0],
"owner_id": owner[1],
"backend": "chat",
"endpoint": subpath,
"model": cfg["gateway_model"],
"user_agent": user_agent,
**timing,
}
def finalize(status_code, success, category, usage=None):
base["total_latency_ms"] = round((time.monotonic() - handle_start) * 1000, 3)
base["gateway_overhead_ms"] = round(max(
base["total_latency_ms"] - timing["upstream_latency_ms"] - timing["queue_wait_ms"], 0.0), 3)
base["total_latency_ms"] = round(
(time.monotonic() - handle_start) * 1000, 3
)
base["gateway_overhead_ms"] = round(
max(
base["total_latency_ms"]
- timing["upstream_latency_ms"]
- timing["queue_wait_ms"],
0.0,
),
3,
)
base["status_code"] = status_code
base["success"] = success
base["error_category"] = category
@@ -281,21 +404,50 @@ class GatewayRuntime:
if timing["circuit_open"]:
finalize(503, False, "circuit_open")
return JSONResponse(status_code=503, content={"error": {"message": "Upstream temporarily unavailable", "type": "circuit_open"}})
return JSONResponse(
status_code=503,
content={
"error": {
"message": "Upstream temporarily unavailable",
"type": "circuit_open",
}
},
)
if exc is not None:
finalize(502, False, classify_error(0, exc))
return JSONResponse(status_code=502, content={"error": {"message": f"Upstream connection failed: {exc}", "type": "upstream_error"}})
return JSONResponse(
status_code=502,
content={
"error": {
"message": f"Upstream connection failed: {exc}",
"type": "upstream_error",
}
},
)
usage = None
if resp.status_code < 400 and "application/json" in (resp.headers.get("content-type") or ""):
if resp.status_code < 400 and "application/json" in (
resp.headers.get("content-type") or ""
):
try:
usage = resp.json().get("usage")
except ValueError:
usage = None
finalize(resp.status_code, resp.status_code < 400,
None if resp.status_code < 400 else classify_error(resp.status_code, None, resp.text), usage)
log(f"passthrough {method} {url} -> {resp.status_code} ({timing['upstream_latency_ms']:.0f}ms)")
return Response(content=resp.content, status_code=resp.status_code,
media_type=resp.headers.get("content-type"))
finalize(
resp.status_code,
resp.status_code < 400,
None
if resp.status_code < 400
else classify_error(resp.status_code, None, resp.text),
usage,
)
log(
f"passthrough {method} {url} -> {resp.status_code} ({timing['upstream_latency_ms']:.0f}ms)"
)
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type"),
)
def metrics(self) -> dict:
return {
@@ -81,7 +81,9 @@ async def retry_send(
last_exc = exc
if attempts > max_retries:
return None, exc, attempts
log(f"upstream connection failed, retrying ({attempts}/{max_retries}): {exc}")
log(
f"upstream connection failed, retrying ({attempts}/{max_retries}): {exc}"
)
await _backoff(backoff_ms, attempts)
continue
if resp.status_code >= 500 and attempts <= max_retries:
+280 -89
View File
@@ -36,81 +36,237 @@ class GatewayService(BaseService):
"instances setting."
)
config_fields = [
ConfigField("gateway_upstream_url", "Upstream URL", type="url", default=config.UPSTREAM_URL_DEFAULT,
help="OpenAI-compatible chat-completions endpoint requests are forwarded to.",
group="Upstream"),
ConfigField("gateway_model", "Model", type="str", default=config.MODEL_DEFAULT,
help="Model sent upstream.", group="Upstream"),
ConfigField("gateway_force_model", "Force model", type="bool", default=True,
help="Override the client-requested model with the configured model.", group="Upstream"),
ConfigField("gateway_api_key", "Upstream API key", type="str", default="",
help="The key currently in use; auto-migrated from DEEPSEEK_API_KEY or OPENROUTER_API_KEY on boot. Editable.",
group="Upstream"),
ConfigField("gateway_timeout", "Upstream timeout (seconds)", type="int", default=config.TIMEOUT_DEFAULT, minimum=config.TIMEOUT_MIN,
help="Per-request upstream timeout. Minimum five minutes.", group="Upstream"),
ConfigField("gateway_instances", "Instances (concurrency)", type="int",
default=config.INSTANCES_DEFAULT, minimum=1, maximum=64,
help="Max concurrent upstream forwards per worker (connection pool + semaphore).",
group="Upstream"),
ConfigField("gateway_vision_enabled", "Vision augmentation", type="bool", default=True,
help="Describe image content via the vision model before forwarding.", group="Vision"),
ConfigField("gateway_vision_url", "Vision URL", type="url", default=config.VISION_URL_DEFAULT,
help="OpenAI-compatible endpoint used to describe images.", group="Vision"),
ConfigField("gateway_vision_model", "Vision model", type="str", default=config.VISION_MODEL_DEFAULT,
help="Vision-capable model name.", group="Vision"),
ConfigField("gateway_vision_key", "Vision API key", type="str", default="",
help="The key currently in use; auto-migrated from OPENROUTER_API_KEY on boot. Editable.",
group="Vision"),
ConfigField("gateway_vision_cache_size", "Vision cache size", type="int",
default=config.VISION_CACHE_SIZE_DEFAULT, minimum=0,
help="Image-description LRU cache entries (0 disables caching).", group="Vision"),
ConfigField("gateway_require_auth", "Require authentication", type="bool", default=True,
help="When off, the gateway is open to anyone.", group="Access"),
ConfigField("gateway_allow_admins", "Allow admins", type="bool", default=True,
help="Admin users (API key / Bearer / Basic / session) may call the gateway.", group="Access"),
ConfigField("gateway_allow_users", "Allow users", type="bool", default=True,
help="Any authenticated user may call the gateway with their own API key. "
"Devii operates a signed-in user's account with that user's key, so usage is "
"attributed and limitable per user.", group="Access"),
ConfigField("gateway_access_key", "Static access key", type="password", default="", secret=True,
help="A standalone key that always grants access (sent as X-API-KEY or Bearer).",
group="Access"),
ConfigField("gateway_internal_key", "Internal key", type="str", default="",
help="Auto-generated on boot. DevPlace's own services authenticate to the gateway "
"with this key. Clear it and restart to rotate.",
group="Access"),
ConfigField("gateway_price_cache_hit_per_m", "Chat price cache-hit / 1M ($)", type="float",
default=config.PRICE_CACHE_HIT_PER_M_DEFAULT, minimum=0,
help="Estimates chat cost when the upstream returns no native cost field (DeepSeek).",
group="Pricing"),
ConfigField("gateway_price_cache_miss_per_m", "Chat price cache-miss / 1M ($)", type="float",
default=config.PRICE_CACHE_MISS_PER_M_DEFAULT, minimum=0, group="Pricing"),
ConfigField("gateway_price_output_per_m", "Chat price output / 1M ($)", type="float",
default=config.PRICE_OUTPUT_PER_M_DEFAULT, minimum=0, group="Pricing"),
ConfigField("gateway_vision_price_input_per_m", "Vision price input / 1M ($)", type="float",
default=config.VISION_PRICE_INPUT_PER_M_DEFAULT, minimum=0,
help="Fallback only; used when the vision upstream returns no native cost.",
group="Pricing"),
ConfigField("gateway_vision_price_output_per_m", "Vision price output / 1M ($)", type="float",
default=config.VISION_PRICE_OUTPUT_PER_M_DEFAULT, minimum=0, group="Pricing"),
ConfigField("gateway_max_retries", "Max retries", type="int", default=config.MAX_RETRIES_DEFAULT,
minimum=0, maximum=10,
help="Retry attempts on timeout, connection error, or upstream 5xx.", group="Reliability"),
ConfigField("gateway_retry_backoff_ms", "Retry backoff (ms)", type="int",
default=config.RETRY_BACKOFF_MS_DEFAULT, minimum=0,
help="Linear backoff multiplied by the attempt number.", group="Reliability"),
ConfigField("gateway_circuit_threshold", "Circuit breaker threshold", type="int",
default=config.CIRCUIT_THRESHOLD_DEFAULT, minimum=0,
help="Consecutive upstream failures before the breaker opens (0 disables).", group="Reliability"),
ConfigField("gateway_circuit_cooldown_seconds", "Circuit breaker cooldown (s)", type="int",
default=config.CIRCUIT_COOLDOWN_SECONDS_DEFAULT, minimum=1, group="Reliability"),
ConfigField("gateway_usage_retention_hours", "Usage retention (hours)", type="int",
default=config.USAGE_RETENTION_HOURS_DEFAULT, minimum=1,
help="How long per-call usage rows are kept before pruning.", group="Tracking"),
ConfigField("gateway_model_context_map", "Model context map (JSON)", type="str",
default=json.dumps(config.MODEL_CONTEXT_MAP_DEFAULT),
help="JSON object mapping model name to max context tokens for utilization tracking.",
group="Tracking"),
ConfigField(
"gateway_upstream_url",
"Upstream URL",
type="url",
default=config.UPSTREAM_URL_DEFAULT,
help="OpenAI-compatible chat-completions endpoint requests are forwarded to.",
group="Upstream",
),
ConfigField(
"gateway_model",
"Model",
type="str",
default=config.MODEL_DEFAULT,
help="Model sent upstream.",
group="Upstream",
),
ConfigField(
"gateway_force_model",
"Force model",
type="bool",
default=True,
help="Override the client-requested model with the configured model.",
group="Upstream",
),
ConfigField(
"gateway_api_key",
"Upstream API key",
type="str",
default="",
help="The key currently in use; auto-migrated from DEEPSEEK_API_KEY or OPENROUTER_API_KEY on boot. Editable.",
group="Upstream",
),
ConfigField(
"gateway_timeout",
"Upstream timeout (seconds)",
type="int",
default=config.TIMEOUT_DEFAULT,
minimum=config.TIMEOUT_MIN,
help="Per-request upstream timeout. Minimum five minutes.",
group="Upstream",
),
ConfigField(
"gateway_instances",
"Instances (concurrency)",
type="int",
default=config.INSTANCES_DEFAULT,
minimum=1,
maximum=64,
help="Max concurrent upstream forwards per worker (connection pool + semaphore).",
group="Upstream",
),
ConfigField(
"gateway_vision_enabled",
"Vision augmentation",
type="bool",
default=True,
help="Describe image content via the vision model before forwarding.",
group="Vision",
),
ConfigField(
"gateway_vision_url",
"Vision URL",
type="url",
default=config.VISION_URL_DEFAULT,
help="OpenAI-compatible endpoint used to describe images.",
group="Vision",
),
ConfigField(
"gateway_vision_model",
"Vision model",
type="str",
default=config.VISION_MODEL_DEFAULT,
help="Vision-capable model name.",
group="Vision",
),
ConfigField(
"gateway_vision_key",
"Vision API key",
type="str",
default="",
help="The key currently in use; auto-migrated from OPENROUTER_API_KEY on boot. Editable.",
group="Vision",
),
ConfigField(
"gateway_vision_cache_size",
"Vision cache size",
type="int",
default=config.VISION_CACHE_SIZE_DEFAULT,
minimum=0,
help="Image-description LRU cache entries (0 disables caching).",
group="Vision",
),
ConfigField(
"gateway_require_auth",
"Require authentication",
type="bool",
default=True,
help="When off, the gateway is open to anyone.",
group="Access",
),
ConfigField(
"gateway_allow_admins",
"Allow admins",
type="bool",
default=True,
help="Admin users (API key / Bearer / Basic / session) may call the gateway.",
group="Access",
),
ConfigField(
"gateway_allow_users",
"Allow users",
type="bool",
default=True,
help="Any authenticated user may call the gateway with their own API key. "
"Devii operates a signed-in user's account with that user's key, so usage is "
"attributed and limitable per user.",
group="Access",
),
ConfigField(
"gateway_access_key",
"Static access key",
type="password",
default="",
secret=True,
help="A standalone key that always grants access (sent as X-API-KEY or Bearer).",
group="Access",
),
ConfigField(
"gateway_internal_key",
"Internal key",
type="str",
default="",
help="Auto-generated on boot. DevPlace's own services authenticate to the gateway "
"with this key. Clear it and restart to rotate.",
group="Access",
),
ConfigField(
"gateway_price_cache_hit_per_m",
"Chat price cache-hit / 1M ($)",
type="float",
default=config.PRICE_CACHE_HIT_PER_M_DEFAULT,
minimum=0,
help="Estimates chat cost when the upstream returns no native cost field (DeepSeek).",
group="Pricing",
),
ConfigField(
"gateway_price_cache_miss_per_m",
"Chat price cache-miss / 1M ($)",
type="float",
default=config.PRICE_CACHE_MISS_PER_M_DEFAULT,
minimum=0,
group="Pricing",
),
ConfigField(
"gateway_price_output_per_m",
"Chat price output / 1M ($)",
type="float",
default=config.PRICE_OUTPUT_PER_M_DEFAULT,
minimum=0,
group="Pricing",
),
ConfigField(
"gateway_vision_price_input_per_m",
"Vision price input / 1M ($)",
type="float",
default=config.VISION_PRICE_INPUT_PER_M_DEFAULT,
minimum=0,
help="Fallback only; used when the vision upstream returns no native cost.",
group="Pricing",
),
ConfigField(
"gateway_vision_price_output_per_m",
"Vision price output / 1M ($)",
type="float",
default=config.VISION_PRICE_OUTPUT_PER_M_DEFAULT,
minimum=0,
group="Pricing",
),
ConfigField(
"gateway_max_retries",
"Max retries",
type="int",
default=config.MAX_RETRIES_DEFAULT,
minimum=0,
maximum=10,
help="Retry attempts on timeout, connection error, or upstream 5xx.",
group="Reliability",
),
ConfigField(
"gateway_retry_backoff_ms",
"Retry backoff (ms)",
type="int",
default=config.RETRY_BACKOFF_MS_DEFAULT,
minimum=0,
help="Linear backoff multiplied by the attempt number.",
group="Reliability",
),
ConfigField(
"gateway_circuit_threshold",
"Circuit breaker threshold",
type="int",
default=config.CIRCUIT_THRESHOLD_DEFAULT,
minimum=0,
help="Consecutive upstream failures before the breaker opens (0 disables).",
group="Reliability",
),
ConfigField(
"gateway_circuit_cooldown_seconds",
"Circuit breaker cooldown (s)",
type="int",
default=config.CIRCUIT_COOLDOWN_SECONDS_DEFAULT,
minimum=1,
group="Reliability",
),
ConfigField(
"gateway_usage_retention_hours",
"Usage retention (hours)",
type="int",
default=config.USAGE_RETENTION_HOURS_DEFAULT,
minimum=1,
help="How long per-call usage rows are kept before pruning.",
group="Tracking",
),
ConfigField(
"gateway_model_context_map",
"Model context map (JSON)",
type="str",
default=json.dumps(config.MODEL_CONTEXT_MAP_DEFAULT),
help="JSON object mapping model name to max context tokens for utilization tracking.",
group="Tracking",
),
]
def __init__(self):
@@ -124,11 +280,14 @@ class GatewayService(BaseService):
def effective_config(self) -> dict:
cfg = self.get_config()
cfg["gateway_api_key"] = (cfg["gateway_api_key"]
or os.environ.get("DEEPSEEK_API_KEY", "")
or os.environ.get("OPENROUTER_API_KEY", ""))
cfg["gateway_vision_key"] = (cfg["gateway_vision_key"]
or os.environ.get("OPENROUTER_API_KEY", ""))
cfg["gateway_api_key"] = (
cfg["gateway_api_key"]
or os.environ.get("DEEPSEEK_API_KEY", "")
or os.environ.get("OPENROUTER_API_KEY", "")
)
cfg["gateway_vision_key"] = cfg["gateway_vision_key"] or os.environ.get(
"OPENROUTER_API_KEY", ""
)
return cfg
def authorize(self, request: Request) -> bool:
@@ -153,9 +312,17 @@ class GatewayService(BaseService):
def resolve_owner(self, request: Request) -> tuple:
cfg = self.get_config()
presented = _presented_key(request)
if presented and cfg["gateway_internal_key"] and presented == cfg["gateway_internal_key"]:
if (
presented
and cfg["gateway_internal_key"]
and presented == cfg["gateway_internal_key"]
):
return ("internal", "devii")
if presented and cfg["gateway_access_key"] and presented == cfg["gateway_access_key"]:
if (
presented
and cfg["gateway_access_key"]
and presented == cfg["gateway_access_key"]
):
return ("key", "access")
user = get_current_user(request)
if user:
@@ -185,7 +352,16 @@ class GatewayService(BaseService):
return await runtime.handle_chat(body, cfg, owner, user_agent, self.log)
body = await request.body()
content_type = request.headers.get("content-type", "")
return await runtime.handle_passthrough(request.method, subpath, content_type, body, cfg, owner, user_agent, self.log)
return await runtime.handle_passthrough(
request.method,
subpath,
content_type,
body,
cfg,
owner,
user_agent,
self.log,
)
async def run_once(self) -> None:
if not self.is_enabled():
@@ -193,10 +369,14 @@ class GatewayService(BaseService):
runtime = self.runtime()
runtime._ensure(self.effective_config())
runtime._ledger.sample_concurrency(runtime.in_flight)
retention = get_int_setting("gateway_usage_retention_hours", config.USAGE_RETENTION_HOURS_DEFAULT)
retention = get_int_setting(
"gateway_usage_retention_hours", config.USAGE_RETENTION_HOURS_DEFAULT
)
ledger_removed, samples_removed = runtime._ledger.prune(retention)
if ledger_removed or samples_removed:
self.log(f"Pruned {ledger_removed} usage rows and {samples_removed} concurrency samples")
self.log(
f"Pruned {ledger_removed} usage rows and {samples_removed} concurrency samples"
)
async def on_disable(self) -> None:
if self._runtime is not None:
@@ -204,10 +384,21 @@ class GatewayService(BaseService):
def collect_metrics(self) -> dict:
cfg = self.get_config()
m = self._runtime.metrics() if self._runtime is not None else {
"requests": 0, "errors": 0, "in_flight": 0, "peak_in_flight": 0, "vision_calls": 0,
"last_status": 0, "last_latency_ms": 0, "pool": 0, "circuit_open": False,
}
m = (
self._runtime.metrics()
if self._runtime is not None
else {
"requests": 0,
"errors": 0,
"in_flight": 0,
"peak_in_flight": 0,
"vision_calls": 0,
"last_status": 0,
"last_latency_ms": 0,
"pool": 0,
"circuit_open": False,
}
)
s = summary_metrics()
stats = [
{"label": "Requests (lifetime)", "value": m["requests"]},
+78 -24
View File
@@ -39,11 +39,31 @@ class Pricing:
def pricing_from_cfg(cfg: dict) -> Pricing:
return Pricing(
chat_cache_hit_per_m=float(cfg.get("gateway_price_cache_hit_per_m", config.PRICE_CACHE_HIT_PER_M_DEFAULT)),
chat_cache_miss_per_m=float(cfg.get("gateway_price_cache_miss_per_m", config.PRICE_CACHE_MISS_PER_M_DEFAULT)),
chat_output_per_m=float(cfg.get("gateway_price_output_per_m", config.PRICE_OUTPUT_PER_M_DEFAULT)),
vision_input_per_m=float(cfg.get("gateway_vision_price_input_per_m", config.VISION_PRICE_INPUT_PER_M_DEFAULT)),
vision_output_per_m=float(cfg.get("gateway_vision_price_output_per_m", config.VISION_PRICE_OUTPUT_PER_M_DEFAULT)),
chat_cache_hit_per_m=float(
cfg.get(
"gateway_price_cache_hit_per_m", config.PRICE_CACHE_HIT_PER_M_DEFAULT
)
),
chat_cache_miss_per_m=float(
cfg.get(
"gateway_price_cache_miss_per_m", config.PRICE_CACHE_MISS_PER_M_DEFAULT
)
),
chat_output_per_m=float(
cfg.get("gateway_price_output_per_m", config.PRICE_OUTPUT_PER_M_DEFAULT)
),
vision_input_per_m=float(
cfg.get(
"gateway_vision_price_input_per_m",
config.VISION_PRICE_INPUT_PER_M_DEFAULT,
)
),
vision_output_per_m=float(
cfg.get(
"gateway_vision_price_output_per_m",
config.VISION_PRICE_OUTPUT_PER_M_DEFAULT,
)
),
)
@@ -90,7 +110,9 @@ def normalize_usage(usage: Optional[dict]) -> dict:
}
def compute_cost(usage: dict, norm: dict, pricing: Pricing, backend: str) -> tuple[float, float, float, bool]:
def compute_cost(
usage: dict, norm: dict, pricing: Pricing, backend: str
) -> tuple[float, float, float, bool]:
native = usage.get("cost") if isinstance(usage, dict) else None
if isinstance(native, (int, float)) and not isinstance(native, bool):
total = float(native)
@@ -101,13 +123,17 @@ def compute_cost(usage: dict, norm: dict, pricing: Pricing, backend: str) -> tup
input_cost = norm["prompt"] / PER_MILLION * pricing.vision_input_per_m
output_cost = norm["completion"] / PER_MILLION * pricing.vision_output_per_m
return input_cost + output_cost, input_cost, output_cost, False
input_cost = (norm["cache_hit"] / PER_MILLION * pricing.chat_cache_hit_per_m
+ norm["cache_miss"] / PER_MILLION * pricing.chat_cache_miss_per_m)
input_cost = (
norm["cache_hit"] / PER_MILLION * pricing.chat_cache_hit_per_m
+ norm["cache_miss"] / PER_MILLION * pricing.chat_cache_miss_per_m
)
output_cost = norm["completion"] / PER_MILLION * pricing.chat_output_per_m
return input_cost + output_cost, input_cost, output_cost, False
def context_utilization(total_tokens: int, model: str, context_map: dict) -> tuple[Optional[int], Optional[float]]:
def context_utilization(
total_tokens: int, model: str, context_map: dict
) -> tuple[Optional[int], Optional[float]]:
window = context_map.get(model)
if not window or window <= 0:
return None, None
@@ -116,8 +142,14 @@ def context_utilization(total_tokens: int, model: str, context_map: dict) -> tup
def extract_params(body: Any) -> dict:
if not isinstance(body, dict):
return {"requested_model": "", "stream_requested": False, "temperature": None,
"top_p": None, "max_tokens": None, "has_tools": False}
return {
"requested_model": "",
"stream_requested": False,
"temperature": None,
"top_p": None,
"max_tokens": None,
"has_tools": False,
}
temperature = body.get("temperature")
top_p = body.get("top_p")
max_tokens = body.get("max_tokens")
@@ -126,14 +158,22 @@ def extract_params(body: Any) -> dict:
return {
"requested_model": body.get("model") or "",
"stream_requested": bool(body.get("stream")),
"temperature": float(temperature) if isinstance(temperature, (int, float)) and not isinstance(temperature, bool) else None,
"top_p": float(top_p) if isinstance(top_p, (int, float)) and not isinstance(top_p, bool) else None,
"max_tokens": int(max_tokens) if isinstance(max_tokens, (int, float)) and not isinstance(max_tokens, bool) else None,
"temperature": float(temperature)
if isinstance(temperature, (int, float)) and not isinstance(temperature, bool)
else None,
"top_p": float(top_p)
if isinstance(top_p, (int, float)) and not isinstance(top_p, bool)
else None,
"max_tokens": int(max_tokens)
if isinstance(max_tokens, (int, float)) and not isinstance(max_tokens, bool)
else None,
"has_tools": bool(body.get("tools") or body.get("functions")),
}
def classify_error(status_code: int, exc: Optional[Exception] = None, message: str = "") -> str:
def classify_error(
status_code: int, exc: Optional[Exception] = None, message: str = ""
) -> str:
if exc is not None:
if isinstance(exc, httpx.TimeoutException):
return "timeout"
@@ -159,11 +199,19 @@ class GatewayUsageLedger:
try:
usage = raw.get("usage") or {}
norm = normalize_usage(usage)
cost_usd, input_cost, output_cost, native = compute_cost(usage, norm, pricing, raw["backend"])
window, util = context_utilization(norm["total"], raw.get("model") or "", context_map)
cost_usd, input_cost, output_cost, native = compute_cost(
usage, norm, pricing, raw["backend"]
)
window, util = context_utilization(
norm["total"], raw.get("model") or "", context_map
)
upstream_ms = float(raw.get("upstream_latency_ms") or 0)
completion = norm["completion"]
tps = completion / (upstream_ms / 1000.0) if upstream_ms > 0 and completion else 0.0
tps = (
completion / (upstream_ms / 1000.0)
if upstream_ms > 0 and completion
else 0.0
)
row = {
"created_at": _iso(_now()),
"owner_kind": raw.get("owner_kind") or "unknown",
@@ -209,10 +257,12 @@ class GatewayUsageLedger:
def sample_concurrency(self, in_flight: int) -> None:
try:
get_table(GATEWAY_CONCURRENCY).insert({
"created_at": _iso(_now()),
"in_flight": int(in_flight),
})
get_table(GATEWAY_CONCURRENCY).insert(
{
"created_at": _iso(_now()),
"in_flight": int(in_flight),
}
)
except Exception as exc:
logger.warning("gateway concurrency sample failed: %s", exc)
@@ -221,7 +271,11 @@ class GatewayUsageLedger:
ledger_removed = 0
samples_removed = 0
if GATEWAY_LEDGER in db.tables:
ledger_removed = int(get_table(GATEWAY_LEDGER).delete(created_at={"<": cutoff}))
ledger_removed = int(
get_table(GATEWAY_LEDGER).delete(created_at={"<": cutoff})
)
if GATEWAY_CONCURRENCY in db.tables:
samples_removed = int(get_table(GATEWAY_CONCURRENCY).delete(created_at={"<": cutoff}))
samples_removed = int(
get_table(GATEWAY_CONCURRENCY).delete(created_at={"<": cutoff})
)
return ledger_removed, samples_removed
+67 -25
View File
@@ -42,8 +42,7 @@ def has_vision_blocks(content: Any) -> bool:
if not isinstance(content, list):
return False
return any(
isinstance(b, dict) and b.get("type") in ("image_url", "image")
for b in content
isinstance(b, dict) and b.get("type") in ("image_url", "image") for b in content
)
@@ -73,13 +72,27 @@ def _format_vision_block(descriptions: list) -> str:
if len(descriptions) == 1:
return f"[Image seen by vision model:\n{descriptions[0]}\n]"
parts = [f"Image {i}:\n{d}" for i, d in enumerate(descriptions, 1)]
return f"[{len(descriptions)} images seen by vision model:\n" + "\n\n".join(parts) + "\n]"
return (
f"[{len(descriptions)} images seen by vision model:\n"
+ "\n\n".join(parts)
+ "\n]"
)
class VisionAugmenter:
def __init__(self, vision_url: str, vision_model: str, vision_key: str,
cache: VisionCache, referer: str = "", title: str = "",
ledger=None, owner: tuple = ("unknown", "unknown"), pricing=None, context_map=None):
def __init__(
self,
vision_url: str,
vision_model: str,
vision_key: str,
cache: VisionCache,
referer: str = "",
title: str = "",
ledger=None,
owner: tuple = ("unknown", "unknown"),
pricing=None,
context_map=None,
):
self.vision_url = vision_url
self.vision_model = vision_model
self.vision_key = vision_key
@@ -95,27 +108,40 @@ class VisionAugmenter:
def _record(self, latency_ms, status_code, success, category, usage):
if self.ledger is None or self.pricing is None:
return
self.ledger.record({
"owner_kind": self.owner[0], "owner_id": self.owner[1], "backend": "vision",
"endpoint": "chat/completions", "model": self.vision_model,
"requested_model": self.vision_model, "temperature": 0.2,
"upstream_latency_ms": latency_ms, "total_latency_ms": latency_ms,
"status_code": status_code, "success": success, "error_category": category,
"usage": usage,
}, self.pricing, self.context_map)
self.ledger.record(
{
"owner_kind": self.owner[0],
"owner_id": self.owner[1],
"backend": "vision",
"endpoint": "chat/completions",
"model": self.vision_model,
"requested_model": self.vision_model,
"temperature": 0.2,
"upstream_latency_ms": latency_ms,
"total_latency_ms": latency_ms,
"status_code": status_code,
"success": success,
"error_category": category,
"usage": usage,
},
self.pricing,
self.context_map,
)
async def _describe_one(self, client: httpx.AsyncClient, image_block: dict) -> str:
if not self.vision_key:
return "[vision unavailable: vision API key not configured]"
payload = {
"model": self.vision_model,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": VISION_INSTRUCTION},
image_block,
],
}],
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": VISION_INSTRUCTION},
image_block,
],
}
],
"temperature": 0.2,
"stream": False,
}
@@ -132,12 +158,24 @@ class VisionAugmenter:
resp = await client.post(self.vision_url, json=payload, headers=headers)
except httpx.RequestError as e:
logger.warning("vision connection failed: %s", e)
self._record((time.monotonic() - start) * 1000, 502, False, classify_error(0, e), None)
self._record(
(time.monotonic() - start) * 1000,
502,
False,
classify_error(0, e),
None,
)
return f"[vision call failed: {e}]"
latency_ms = (time.monotonic() - start) * 1000
if resp.status_code != 200:
logger.warning("vision %s: %s", resp.status_code, resp.text[:200])
self._record(latency_ms, resp.status_code, False, classify_error(resp.status_code, None, resp.text), None)
self._record(
latency_ms,
resp.status_code,
False,
classify_error(resp.status_code, None, resp.text),
None,
)
return f"[vision failed: HTTP {resp.status_code}]"
try:
data = resp.json()
@@ -154,7 +192,9 @@ class VisionAugmenter:
miss = [i for i, d in enumerate(descriptions) if d is None]
if miss:
self.calls += len(miss)
results = await asyncio.gather(*[self._describe_one(client, images[i]) for i in miss])
results = await asyncio.gather(
*[self._describe_one(client, images[i]) for i in miss]
)
for idx, result in zip(miss, results):
descriptions[idx] = result
if not result.startswith("[vision"):
@@ -162,7 +202,9 @@ class VisionAugmenter:
return [d or "" for d in descriptions]
async def augment_messages(self, client: httpx.AsyncClient, messages: list) -> list:
if not any(has_vision_blocks(m.get("content")) for m in messages if isinstance(m, dict)):
if not any(
has_vision_blocks(m.get("content")) for m in messages if isinstance(m, dict)
):
return messages
out: list = []
for m in messages: