docs: document server-side rendering pipeline, response timing middleware, and Telegram pairing API
- Add comprehensive documentation for backend content rendering in AGENTS.md, detailing the new `render_content` and `render_title` Jinja globals built on mistune with media processing, emoji shortcodes, and XSS protection
- Document the `X-Response-Time` header and bottom-left render time indicator in README.md
- Update bot token pricing documentation to clarify fallback vs gateway cost headers
- Add `email_accounts` to soft-delete tables and `idx_users_role` composite index in database schema
- Implement `telegram_pairings` and `telegram_links` table creation with column migration and indexes
- Add `/profile/{username}/telegram` endpoint to docs API with request/unpair actions
- Register `TelegramService` in main.py lifespan and add `response_timing` middleware emitting `X-Response-Time` header
- Introduce `TelegramPairForm` model and `guard_public_host_sync` synchronous host validation function
This commit is contained in:
@@ -336,16 +336,22 @@ class DevPlaceBot:
|
||||
return ""
|
||||
return (key or "").strip()
|
||||
|
||||
async def _adopt_account_api_key(self) -> None:
|
||||
async def _adopt_account_api_key(self) -> bool:
|
||||
if self.state.account_api_key:
|
||||
return
|
||||
key = await self._fetch_account_api_key()
|
||||
if not key:
|
||||
return
|
||||
self.state.account_api_key = key
|
||||
self.llm.api_key = key
|
||||
self._save()
|
||||
self._log("Adopted account API key for gateway calls")
|
||||
self.llm.api_key = self.state.account_api_key
|
||||
return True
|
||||
for attempt in range(5):
|
||||
key = await self._fetch_account_api_key()
|
||||
if key:
|
||||
self.state.account_api_key = key
|
||||
self.llm.api_key = key
|
||||
self._save()
|
||||
self._log("Adopted account API key for gateway calls")
|
||||
return True
|
||||
if attempt < 4:
|
||||
await asyncio.sleep(2)
|
||||
self._log("Account API key unavailable after retries")
|
||||
return False
|
||||
|
||||
async def _read_like_human(self, min_s: float = 3.0, max_s: float = 90.0) -> None:
|
||||
if not self.state.reading_speed_wpm:
|
||||
@@ -2833,7 +2839,11 @@ class DevPlaceBot:
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
await self._adopt_account_api_key()
|
||||
if not await self._adopt_account_api_key():
|
||||
self._log("No own API key yet; ending session to avoid shared-key billing")
|
||||
await b.close()
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
self._bind_monitor()
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ BASE_URL_DEFAULT = "https://pravda.education"
|
||||
API_URL_DEFAULT = INTERNAL_GATEWAY_URL
|
||||
NEWS_API_DEFAULT = "https://news.app.molodetz.nl/api"
|
||||
MODEL_DEFAULT = INTERNAL_MODEL
|
||||
INPUT_COST_PER_1M_DEFAULT = 0.27
|
||||
OUTPUT_COST_PER_1M_DEFAULT = 1.10
|
||||
INPUT_COST_PER_1M_DEFAULT = 0.14
|
||||
OUTPUT_COST_PER_1M_DEFAULT = 0.28
|
||||
|
||||
COST_WINDOW_SECONDS = 600
|
||||
COST_WARMUP_SECONDS = 120
|
||||
|
||||
@@ -9,15 +9,16 @@ from devplacepy.services.bot.config import HANDLE_SUFFIX_WORDS
|
||||
MIN_HANDLE_LEN = 3
|
||||
MAX_HANDLE_LEN = 20
|
||||
MIN_SEED_TOKEN_LEN = 3
|
||||
LEET_PROBABILITY = 0.35
|
||||
LEET_CHAR_PROBABILITY = 0.5
|
||||
NUMBER_SUFFIX_PROBABILITY = 0.30
|
||||
SEPARATOR_PROBABILITY = 0.30
|
||||
CAPITALIZE_PROBABILITY = 0.22
|
||||
ALLCAPS_PROBABILITY = 0.08
|
||||
LEET_CHAR_PROBABILITY = 0.35
|
||||
NUMBER_SUFFIX_PROBABILITY = 0.26
|
||||
DECORATION_STYLES = ["plain", "leet", "vowel"]
|
||||
DECORATION_WEIGHTS = [64, 22, 14]
|
||||
CAPITALIZE_PROBABILITY = 0.20
|
||||
ALLCAPS_PROBABILITY = 0.06
|
||||
ALLCAPS_MAX_LEN = 8
|
||||
PAD_NUMBER_FLOOR = 10
|
||||
PAD_NUMBER_CEIL = 9999
|
||||
VOWELS = "aeiou"
|
||||
|
||||
LEET_MAP = {
|
||||
"a": "4",
|
||||
@@ -39,33 +40,60 @@ TECH_NOUNS = [
|
||||
"mutex", "syntax", "binary", "octet", "raster", "shader", "codec",
|
||||
"vertex", "matrix", "scalar", "payload", "opcode", "runtime", "compiler",
|
||||
"parser", "lexer", "sandbox", "firmware", "bytecode", "checksum",
|
||||
"entropy", "signal", "overflow", "nibbler", "cursor", "patch",
|
||||
"entropy", "signal", "overflow", "nibbler", "cursor", "patch", "semaphore",
|
||||
"spinlock", "deadlock", "webhook", "endpoint", "gateway", "registry",
|
||||
"pipeline", "tensor", "gradient", "cluster", "shard", "replica", "schema",
|
||||
"commit", "branch", "rebase", "stash", "blob", "symlink", "inode", "mmap",
|
||||
"syscall", "interrupt", "watchdog", "heartbeat", "beacon", "subnet",
|
||||
"datagram", "loopback", "nullref", "dangle", "leak", "trace", "kilobyte",
|
||||
"qubit", "monad", "closure", "thunk", "fiber", "coro", "atom",
|
||||
]
|
||||
|
||||
HANDLE_ADJECTIVES = [
|
||||
"dark", "mad", "lazy", "quantum", "cyber", "neon", "ghost", "hyper",
|
||||
"ultra", "neo", "retro", "raw", "zero", "async", "blind", "silent",
|
||||
"atomic", "fatal", "rogue", "idle", "stale", "dirty", "lucid", "prime",
|
||||
"hex", "cold", "lone", "wired", "broken", "feral",
|
||||
"hex", "cold", "lone", "wired", "broken", "feral", "stray", "numb",
|
||||
"vivid", "molten", "frozen", "hollow", "jagged", "static", "volatile",
|
||||
"nomadic", "unsigned", "verbose", "terse", "nocturnal", "caffeinated",
|
||||
"deprecated", "headless", "stateless", "immutable", "recursive",
|
||||
]
|
||||
|
||||
HANDLE_CREATURES = [
|
||||
"fox", "wolf", "raven", "owl", "octopus", "gecko", "mantis", "viper",
|
||||
"lynx", "moth", "hydra", "kraken", "phoenix", "dragon", "falcon",
|
||||
"panther", "cobra", "badger", "otter", "sloth", "hornet", "beetle",
|
||||
"lemur", "narwhal", "basilisk",
|
||||
"lemur", "narwhal", "basilisk", "magpie", "heron", "ferret", "weasel",
|
||||
"jackal", "marten", "axolotl", "tardigrade", "wyvern", "griffin",
|
||||
"salamander", "newt", "stoat", "crow", "shrike", "ibis",
|
||||
]
|
||||
|
||||
HANDLE_PREFIXES = ["the", "mr", "dr", "lord", "captain", "uncle", "sir", "prof", "agent"]
|
||||
HANDLE_PREFIXES = [
|
||||
"the", "mr", "dr", "lord", "captain", "uncle", "sir", "prof", "agent",
|
||||
"old", "lil", "big", "saint", "baron", "comrade", "major",
|
||||
]
|
||||
|
||||
NUMBER_SUFFIXES = ["42", "1337", "404", "64", "99", "007", "23", "88", "256", "512", "101", "777"]
|
||||
ROLE_WORDS = [
|
||||
"dev", "coder", "hacker", "ninja", "guru", "wizard", "smith", "monk",
|
||||
"nomad", "ghost", "pilot", "surgeon", "gremlin", "goblin", "wrangler",
|
||||
"jockey", "slinger", "herder", "whisperer", "monkey", "sage", "druid",
|
||||
"ronin", "samurai", "pirate", "tinkerer", "alchemist", "janitor",
|
||||
"plumber", "gardener", "scribe", "warden", "ranger", "miner", "forge",
|
||||
]
|
||||
|
||||
SEPARATORS = ["_", "-"]
|
||||
NUMBER_SUFFIXES = [
|
||||
"42", "1337", "404", "64", "99", "007", "23", "88", "256", "512", "101",
|
||||
"777", "13", "21", "69", "420", "127", "255", "80", "443", "3000", "8080",
|
||||
"2k", "9000", "65535", "16", "32", "128",
|
||||
]
|
||||
|
||||
VERSION_TAGS = [
|
||||
"v2", "v3", "x64", "x86", "js", "py", "sh", "io", "fn", "db", "ai", "os",
|
||||
"hq", "exe", "dev", "rs", "go", "cpp", "ml",
|
||||
]
|
||||
|
||||
def _join(rng: random.Random, parts: list[str]) -> str:
|
||||
separator = rng.choice(SEPARATORS) if rng.random() < SEPARATOR_PROBABILITY else ""
|
||||
return separator.join(parts)
|
||||
JOIN_STYLES = ["none", "under", "hyphen", "camel"]
|
||||
JOIN_WEIGHTS = [46, 19, 12, 23]
|
||||
|
||||
|
||||
def _seed_tokens(interests: list[str]) -> list[str]:
|
||||
@@ -77,26 +105,81 @@ def _seed_tokens(interests: list[str]) -> list[str]:
|
||||
return tokens
|
||||
|
||||
|
||||
def _build_core(rng: random.Random, interests: list[str]) -> str:
|
||||
seeds = _seed_tokens(interests)
|
||||
builders = [
|
||||
lambda: rng.choice(TECH_NOUNS),
|
||||
lambda: _join(rng, [rng.choice(HANDLE_ADJECTIVES), rng.choice(TECH_NOUNS)]),
|
||||
lambda: _join(rng, [rng.choice(TECH_NOUNS), rng.choice(HANDLE_CREATURES)]),
|
||||
lambda: _join(rng, [rng.choice(HANDLE_ADJECTIVES), rng.choice(HANDLE_CREATURES)]),
|
||||
lambda: _join(rng, [rng.choice(HANDLE_PREFIXES), rng.choice(TECH_NOUNS + HANDLE_CREATURES)]),
|
||||
lambda: _join(rng, [rng.choice(TECH_NOUNS), rng.choice(HANDLE_SUFFIX_WORDS)]),
|
||||
def _seed_word(rng: random.Random, seeds: list[str]) -> str:
|
||||
return rng.choice(seeds) if seeds else rng.choice(TECH_NOUNS)
|
||||
|
||||
|
||||
def _tokens(rng: random.Random, seeds: list[str]) -> list[str]:
|
||||
noun = lambda: rng.choice(TECH_NOUNS)
|
||||
adjective = lambda: rng.choice(HANDLE_ADJECTIVES)
|
||||
creature = lambda: rng.choice(HANDLE_CREATURES)
|
||||
role = lambda: rng.choice(ROLE_WORDS)
|
||||
prefix = lambda: rng.choice(HANDLE_PREFIXES)
|
||||
suffix = lambda: rng.choice(HANDLE_SUFFIX_WORDS)
|
||||
seed = lambda: _seed_word(rng, seeds)
|
||||
plans = [
|
||||
(7, lambda: [noun()]),
|
||||
(3, lambda: [role()]),
|
||||
(8, lambda: [adjective(), noun()]),
|
||||
(6, lambda: [noun(), creature()]),
|
||||
(5, lambda: [adjective(), creature()]),
|
||||
(5, lambda: [noun(), role()]),
|
||||
(4, lambda: [adjective(), role()]),
|
||||
(4, lambda: [creature(), role()]),
|
||||
(4, lambda: [prefix(), noun()]),
|
||||
(3, lambda: [prefix(), creature()]),
|
||||
(4, lambda: [role(), noun()]),
|
||||
(3, lambda: [noun(), suffix()]),
|
||||
(3, lambda: [adjective(), suffix()]),
|
||||
(3, lambda: [noun(), noun()]),
|
||||
(2, lambda: [adjective(), noun(), creature()]),
|
||||
(2, lambda: [prefix(), adjective(), noun()]),
|
||||
(6, lambda: [seed(), role()]),
|
||||
(5, lambda: [adjective(), seed()]),
|
||||
(4, lambda: [seed(), noun()]),
|
||||
(3, lambda: [seed()]),
|
||||
]
|
||||
weights = [3, 4, 3, 2, 2, 2]
|
||||
if seeds:
|
||||
builders.append(
|
||||
lambda: _join(rng, [rng.choice(seeds), rng.choice(HANDLE_SUFFIX_WORDS + TECH_NOUNS)])
|
||||
)
|
||||
builders.append(lambda: _join(rng, [rng.choice(HANDLE_ADJECTIVES), rng.choice(seeds)]))
|
||||
weights += [5, 3]
|
||||
weights = [weight for weight, _ in plans]
|
||||
builders = [builder for _, builder in plans]
|
||||
return rng.choices(builders, weights=weights)[0]()
|
||||
|
||||
|
||||
def _dedupe(rng: random.Random, tokens: list[str]) -> list[str]:
|
||||
result: list[str] = []
|
||||
for token in tokens:
|
||||
if result and result[-1] == token:
|
||||
token = rng.choice(TECH_NOUNS)
|
||||
result.append(token)
|
||||
return result
|
||||
|
||||
|
||||
def _fuse(rng: random.Random, tokens: list[str]) -> str:
|
||||
if len(tokens) == 1:
|
||||
return tokens[0]
|
||||
style = rng.choices(JOIN_STYLES, weights=JOIN_WEIGHTS)[0]
|
||||
if style == "camel":
|
||||
head, *rest = tokens
|
||||
return head + "".join(part[:1].upper() + part[1:] for part in rest)
|
||||
separator = {"none": "", "under": "_", "hyphen": "-"}[style]
|
||||
return separator.join(tokens)
|
||||
|
||||
|
||||
def _drop_vowels(rng: random.Random, text: str) -> str:
|
||||
kept = [text[0]]
|
||||
for char in text[1:]:
|
||||
if char in VOWELS and rng.random() < 0.55:
|
||||
continue
|
||||
kept.append(char)
|
||||
result = "".join(kept)
|
||||
return result if len(result) >= MIN_HANDLE_LEN else text
|
||||
|
||||
|
||||
def _maybe_tag(rng: random.Random, core: str) -> str:
|
||||
if rng.random() >= NUMBER_SUFFIX_PROBABILITY:
|
||||
return core
|
||||
return f"{core}{rng.choice(NUMBER_SUFFIXES + VERSION_TAGS)}"
|
||||
|
||||
|
||||
def apply_leet(text: str, rng: random.Random) -> str:
|
||||
chars: list[str] = []
|
||||
for char in text:
|
||||
@@ -108,7 +191,7 @@ def apply_leet(text: str, rng: random.Random) -> str:
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
def _apply_case(text: str, rng: random.Random) -> str:
|
||||
def _apply_case(rng: random.Random, text: str) -> str:
|
||||
roll = rng.random()
|
||||
if roll < ALLCAPS_PROBABILITY and len(text) <= ALLCAPS_MAX_LEN:
|
||||
return text.upper()
|
||||
@@ -125,13 +208,17 @@ def sanitize_handle(raw: str) -> str:
|
||||
|
||||
def make_handle(interests: Optional[list[str]] = None, rng: Optional[random.Random] = None) -> str:
|
||||
generator = rng or random
|
||||
core = _build_core(generator, interests or [])
|
||||
if generator.random() < LEET_PROBABILITY:
|
||||
seeds = _seed_tokens(interests or [])
|
||||
core = _fuse(generator, _dedupe(generator, _tokens(generator, seeds)))
|
||||
decoration = generator.choices(DECORATION_STYLES, weights=DECORATION_WEIGHTS)[0]
|
||||
if decoration == "vowel" and core.isalpha() and core.islower() and len(core) > 6:
|
||||
core = _drop_vowels(generator, core)
|
||||
elif decoration == "leet":
|
||||
core = apply_leet(core, generator)
|
||||
if generator.random() < NUMBER_SUFFIX_PROBABILITY:
|
||||
core = f"{core}{generator.choice(NUMBER_SUFFIXES)}"
|
||||
if decoration != "leet":
|
||||
core = _maybe_tag(generator, core)
|
||||
handle = sanitize_handle(core)
|
||||
if len(handle) < MIN_HANDLE_LEN:
|
||||
handle = f"{handle}{generator.randint(PAD_NUMBER_FLOOR, PAD_NUMBER_CEIL)}"
|
||||
handle = _apply_case(handle, generator)
|
||||
handle = _apply_case(generator, handle)
|
||||
return handle or f"dev{generator.randint(PAD_NUMBER_FLOOR, PAD_NUMBER_CEIL)}"
|
||||
|
||||
@@ -19,6 +19,7 @@ from devplacepy.services.bot.config import (
|
||||
TRIVIAL_GIST_TERMS,
|
||||
)
|
||||
from devplacepy.services.bot.handles import MAX_HANDLE_LEN, sanitize_handle
|
||||
from devplacepy.services.openai_gateway.usage import parse_usage_headers
|
||||
|
||||
HANDLE_CANDIDATE_TARGET = 8
|
||||
|
||||
@@ -74,11 +75,8 @@ class LLMClient:
|
||||
raw = resp.content
|
||||
logger.info("LLM <<< %s", raw[:2000].decode(errors="replace"))
|
||||
result = resp.json()
|
||||
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
|
||||
in_tokens, out_tokens, cost = self._account_usage(
|
||||
resp.headers, result.get("usage", {})
|
||||
)
|
||||
self.total_cost += cost
|
||||
self.total_calls += 1
|
||||
@@ -96,6 +94,21 @@ class LLMClient:
|
||||
time.sleep(2**attempt)
|
||||
return ""
|
||||
|
||||
def _account_usage(self, response_headers, body_usage: dict) -> tuple[int, int, float]:
|
||||
parsed = parse_usage_headers(response_headers)
|
||||
if parsed is not None:
|
||||
in_tokens = parsed["prompt_tokens"]
|
||||
out_tokens = parsed["completion_tokens"]
|
||||
if not out_tokens and parsed["total_tokens"] > in_tokens:
|
||||
out_tokens = parsed["total_tokens"] - in_tokens
|
||||
return in_tokens, out_tokens, parsed["cost_usd"]
|
||||
in_tokens = body_usage.get("prompt_tokens", 0)
|
||||
out_tokens = body_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
|
||||
)
|
||||
return in_tokens, out_tokens, cost
|
||||
|
||||
def _call(self, system: str, prompt: str, temperature: float = 0.7) -> str:
|
||||
return self.clean(self._raw_call(system, prompt, temperature))
|
||||
|
||||
@@ -496,12 +509,20 @@ class LLMClient:
|
||||
"You invent online handles for a developer signing up to a programmer "
|
||||
"community in the style of devRant or Hacker News. The handles read like a "
|
||||
"real nerd picked them, never like a person's full name. Lean on programming "
|
||||
"and hacker culture: tech nouns (null, kernel, segfault, daemon), occasional "
|
||||
"leetspeak (c0d3r, h4x), an adjective plus noun, a creature, or a short word "
|
||||
f"with a number. Lowercase mostly, {MAX_HANDLE_LEN} characters or fewer, only "
|
||||
"letters, digits, underscores and hyphens, no spaces and no dots. Return ONLY "
|
||||
f'a JSON object {{"handles": ["...", "..."]}} with {HANDLE_CANDIDATE_TARGET} '
|
||||
"distinct handles, no prose, no markdown fences. No em dashes."
|
||||
"and hacker culture, but make every handle in the list a DIFFERENT shape so "
|
||||
"they never feel formulaic. Mix these forms across the list: a single fused "
|
||||
"word (segfault, mutexlord, kernelpanic); two words joined with no separator "
|
||||
"(darkbyte, neonferret); camelCase (nullPointer, byteFox); an underscore or a "
|
||||
"hyphen but NOT on most of them (lazy_daemon, cold-stack); leetspeak "
|
||||
"(n0_scalar, c0d3r, h4xwolf); a word plus a number, port, or version tag "
|
||||
"(void404, daemon1337, byte_v2, heap8080); dropped vowels (krnlpnc, bffr, "
|
||||
"mtxguru); and an unexpected creature, role, or prefix (dr_segfault, "
|
||||
"raven_smith, axolotl_dev). Vary the length from 4 to 18. Lowercase mostly "
|
||||
"with the odd capital. Do NOT make them all the 'word_word' underscore "
|
||||
f"pattern. {MAX_HANDLE_LEN} characters or fewer, only letters, digits, "
|
||||
"underscores and hyphens, no spaces and no dots. Return ONLY a JSON object "
|
||||
f'{{"handles": ["...", "..."]}} with {HANDLE_CANDIDATE_TARGET} distinct '
|
||||
"handles, each a distinct shape, no prose, no markdown fences. No em dashes."
|
||||
)
|
||||
prompt = (
|
||||
f"Archetype: {archetype}\n"
|
||||
|
||||
@@ -98,7 +98,7 @@ class BotsService(BaseService):
|
||||
type="float",
|
||||
default=config.INPUT_COST_PER_1M_DEFAULT,
|
||||
minimum=0,
|
||||
help="Used to compute live spend per bot and across the fleet.",
|
||||
help="Fallback pricing, used only when the LLM endpoint returns no gateway cost headers. The internal gateway reports authoritative cache-aware cost per call.",
|
||||
group="LLM",
|
||||
),
|
||||
ConfigField(
|
||||
@@ -107,7 +107,7 @@ class BotsService(BaseService):
|
||||
type="float",
|
||||
default=config.OUTPUT_COST_PER_1M_DEFAULT,
|
||||
minimum=0,
|
||||
help="Used to compute live spend per bot and across the fleet.",
|
||||
help="Fallback pricing, used only when the LLM endpoint returns no gateway cost headers. The internal gateway reports authoritative cache-aware cost per call.",
|
||||
group="LLM",
|
||||
),
|
||||
ConfigField(
|
||||
|
||||
Reference in New Issue
Block a user