forked from retoor/devplacepy
fix: normalize unicode escape sequences and reformat multi-line expressions across codebase
This commit is contained in:
+49
-14
@@ -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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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]},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 "-",
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user