Update
This commit is contained in:
@@ -31,6 +31,25 @@ ADMIN_ACTIONS: tuple[Action, ...] = (
|
||||
params=(query("top_n", "How many top authors to include (1-50)."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_statistics",
|
||||
method="GET",
|
||||
path="/admin/statistics/data",
|
||||
summary="Platform statistics tab data with trends (admin only)",
|
||||
description=(
|
||||
"Returns JSON for one statistics tab: KPI cards with period-over-period deltas, "
|
||||
"time-series points for line charts, breakdown tables, and highlight metrics. "
|
||||
"Tabs: overview, visitors, members, content, engagement, social, ai, devii, "
|
||||
"services, containers, game, awards, moderation, tools, storage."
|
||||
),
|
||||
params=(
|
||||
query("tab", "Tab key (default overview)."),
|
||||
query("hours", "Lookback window in hours (24, 168, 720, 2160, or 0 for all time)."),
|
||||
query("compare", "Include previous-period comparison (1 or 0, default 1)."),
|
||||
query("top_n", "Rows in breakdown tables (default 10)."),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="ai_usage",
|
||||
method="GET",
|
||||
@@ -305,6 +324,18 @@ ADMIN_ACTIONS: tuple[Action, ...] = (
|
||||
params=(path("uid", "Attachment uid to purge."), confirm()),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_revoke_award",
|
||||
method="POST",
|
||||
path="/admin/awards/{uid}/revoke",
|
||||
summary="Revoke a published award (admin only)",
|
||||
description=(
|
||||
"Soft-deletes the award and linked attachments, then recomputes receiver stats. "
|
||||
"Confirmation is required in the UI; Devii should confirm before calling."
|
||||
),
|
||||
params=(path("uid", "Award uid to revoke."), confirm()),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_reset_guest_ai_quota",
|
||||
method="POST",
|
||||
|
||||
@@ -27,7 +27,7 @@ GATEWAY_ACTIONS: tuple[Action, ...] = (
|
||||
summary="Create or update a gateway provider (admin only)",
|
||||
description=(
|
||||
"Adds or updates a named upstream provider. base_url is the OpenAI-compatible "
|
||||
"chat-completions endpoint; the embeddings endpoint is derived from it."
|
||||
"chat-completions endpoint; the embeddings and images endpoints are derived from it."
|
||||
),
|
||||
handler="http",
|
||||
requires_admin=True,
|
||||
@@ -60,7 +60,8 @@ GATEWAY_ACTIONS: tuple[Action, ...] = (
|
||||
summary="List OpenAI gateway model routes (admin only)",
|
||||
description=(
|
||||
"Returns JSON: every source-model route with its provider, target model, kind "
|
||||
"(chat/embed), optional vision model, context window, and per-model pricing economy."
|
||||
"(chat/embed/image), optional vision model, context window, and per-model pricing economy "
|
||||
"(including any tiered/off-peak pricing configured on it)."
|
||||
),
|
||||
handler="http",
|
||||
requires_admin=True,
|
||||
@@ -73,9 +74,14 @@ GATEWAY_ACTIONS: tuple[Action, ...] = (
|
||||
summary="Create or update a gateway model route (admin only)",
|
||||
description=(
|
||||
"Maps a requested source_model onto a provider + target_model, each with its own "
|
||||
"pricing. kind is 'chat' or 'embed'. A vision_model adds image-to-text augmentation "
|
||||
"for chat routes. Prices are USD per 1,000,000 tokens; chat uses cache-hit/cache-miss/"
|
||||
"output, embeddings use input, vision uses input/output."
|
||||
"pricing. kind is 'chat', 'embed', or 'image'. A vision_model adds image-to-text augmentation "
|
||||
"for chat routes. Prices are USD per 1,000,000 tokens for chat/embed/vision; image routes "
|
||||
"use price_input_per_m as a flat USD per generated image. Optionally, a route can also "
|
||||
"charge a different (tier-2) rate once the request's input tokens exceed "
|
||||
"context_tier_threshold_tokens, and/or apply a percentage discount during a fixed "
|
||||
"UTC off-peak window - leave the tier2/off-peak fields unset to keep the flat rates "
|
||||
"above at all times. off_peak_start_minute and off_peak_end_minute must be set "
|
||||
"together (both or neither) or the call is rejected."
|
||||
),
|
||||
handler="http",
|
||||
requires_admin=True,
|
||||
@@ -83,14 +89,22 @@ GATEWAY_ACTIONS: tuple[Action, ...] = (
|
||||
body("source_model", "Model name clients request.", required=True),
|
||||
body("provider", "Provider name, or blank for the default upstream."),
|
||||
body("target_model", "Model name sent upstream.", required=True),
|
||||
body("kind", "Route kind: 'chat' or 'embed'."),
|
||||
body("kind", "Route kind: 'chat', 'embed', or 'image'."),
|
||||
body("vision_provider", "Provider for image description, or blank for the route provider."),
|
||||
body("vision_model", "Vision model name (blank disables the merge)."),
|
||||
Param(name="context_window", location="body", description="Max context tokens (0 = unknown).", required=False, type="integer"),
|
||||
Param(name="price_cache_hit_per_m", location="body", description="USD per 1M cache-hit input tokens.", required=False, type="number"),
|
||||
Param(name="price_cache_miss_per_m", location="body", description="USD per 1M cache-miss input tokens.", required=False, type="number"),
|
||||
Param(name="price_output_per_m", location="body", description="USD per 1M output tokens.", required=False, type="number"),
|
||||
Param(name="price_input_per_m", location="body", description="USD per 1M input tokens (embed/vision).", required=False, type="number"),
|
||||
Param(name="price_input_per_m", location="body", description="USD per 1M input tokens (embed/vision), or flat USD per image for image routes.", required=False, type="number"),
|
||||
Param(name="context_tier_threshold_tokens", location="body", description="Input tokens above which tier-2 rates apply (0 disables tiering).", required=False, type="integer"),
|
||||
Param(name="price_cache_hit_per_m_tier2", location="body", description="Tier-2 USD per 1M cache-hit input tokens (unset = keep tier-1 rate above threshold).", required=False, type="number"),
|
||||
Param(name="price_cache_miss_per_m_tier2", location="body", description="Tier-2 USD per 1M cache-miss input tokens (unset = keep tier-1 rate above threshold).", required=False, type="number"),
|
||||
Param(name="price_output_per_m_tier2", location="body", description="Tier-2 USD per 1M output tokens (unset = keep tier-1 rate above threshold).", required=False, type="number"),
|
||||
Param(name="price_input_per_m_tier2", location="body", description="Tier-2 USD per 1M input tokens, embed/vision (unset = keep tier-1 rate above threshold).", required=False, type="number"),
|
||||
Param(name="off_peak_start_minute", location="body", description="Off-peak window start, UTC minutes since midnight (0-1439). Must be set together with off_peak_end_minute.", required=False, type="integer"),
|
||||
Param(name="off_peak_end_minute", location="body", description="Off-peak window end, UTC minutes since midnight (0-1439). A value less than the start wraps past midnight.", required=False, type="integer"),
|
||||
Param(name="off_peak_discount_pct", location="body", description="Percentage discount (0-100) applied to the active tier's rates during the off-peak window.", required=False, type="number"),
|
||||
Param(name="is_active", location="body", description="Whether the route is active ('1' or '0').", required=False, type="boolean"),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -61,6 +61,21 @@ PROFILE_ACTIONS: tuple[Action, ...] = (
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="give_award",
|
||||
method="POST",
|
||||
path="/profile/{username}/award",
|
||||
summary="Give another member an award on their profile",
|
||||
description=(
|
||||
"Creates a pending award row and enqueues image generation billed to the giver's "
|
||||
"API key. The receiver is notified when generation completes. Cannot award yourself; "
|
||||
"blocked pairs are rejected; cooldowns apply."
|
||||
),
|
||||
params=(
|
||||
path("username", "Receiver username."),
|
||||
body("description", "Short award message (1-125 characters).", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="regenerate_avatar",
|
||||
method="POST",
|
||||
|
||||
@@ -18,7 +18,7 @@ def arg(
|
||||
|
||||
|
||||
TYPES = (
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue."
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, award."
|
||||
)
|
||||
|
||||
NOTIFICATION_ACTIONS: tuple[Action, ...] = (
|
||||
|
||||
@@ -21,32 +21,6 @@ from ..text import html_to_text
|
||||
|
||||
logger = logging.getLogger("devii.fetch")
|
||||
|
||||
CHROME_VERSION = "131"
|
||||
CHROME_VERSION_FULL = "131.0.0.0"
|
||||
STEALTH_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
f"(KHTML, like Gecko) Chrome/{CHROME_VERSION_FULL} Safari/537.36"
|
||||
),
|
||||
"Accept": (
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,"
|
||||
"image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
|
||||
),
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"sec-ch-ua": (
|
||||
f'"Google Chrome";v="{CHROME_VERSION}", "Chromium";v="{CHROME_VERSION}", '
|
||||
'"Not_A Brand";v="24"'
|
||||
),
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Linux"',
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Cache-Control": "max-age=0",
|
||||
"DNT": "1",
|
||||
}
|
||||
TITLE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||
MIN_FETCH_CHARS = 1000
|
||||
ALLOWED_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
|
||||
@@ -230,9 +204,7 @@ class FetchController:
|
||||
raise_5xx: bool = False,
|
||||
) -> tuple[str, str, str, int, dict[str, str]]:
|
||||
limit = self._settings.fetch_max_bytes
|
||||
merged_headers = dict(STEALTH_HEADERS)
|
||||
if headers:
|
||||
merged_headers.update({str(k): str(v) for k, v in headers.items()})
|
||||
merged_headers = {str(k): str(v) for k, v in headers.items()} if headers else {}
|
||||
request_kwargs: dict[str, Any] = {}
|
||||
if json_body is not None:
|
||||
request_kwargs["json"] = json_body
|
||||
@@ -240,43 +212,48 @@ class FetchController:
|
||||
request_kwargs["data"] = form
|
||||
elif content is not None:
|
||||
request_kwargs["content"] = content
|
||||
|
||||
async def _once(client: httpx.AsyncClient, target: str) -> tuple[str, str, str, int, dict[str, str]]:
|
||||
async with client.stream(method, target, **request_kwargs) as response:
|
||||
if raise_5xx and response.status_code >= 500:
|
||||
raise UpstreamError(
|
||||
f"Server returned {response.status_code}.",
|
||||
status=response.status_code,
|
||||
url=target,
|
||||
)
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
if total >= limit:
|
||||
break
|
||||
raw = b"".join(chunks)[:limit]
|
||||
encoding = response.encoding or "utf-8"
|
||||
try:
|
||||
body = raw.decode(encoding, errors="replace")
|
||||
except LookupError:
|
||||
body = raw.decode("utf-8", errors="replace")
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
response_headers = {key: value for key, value in response.headers.items()}
|
||||
return body, str(response.url), content_type, response.status_code, response_headers
|
||||
|
||||
try:
|
||||
async with stealth.stealth_async_client(
|
||||
headers=merged_headers,
|
||||
follow_redirects=True,
|
||||
timeout=self._settings.fetch_timeout_seconds,
|
||||
) as client:
|
||||
async with client.stream(method, url, **request_kwargs) as response:
|
||||
if raise_5xx and response.status_code >= 500:
|
||||
raise UpstreamError(
|
||||
f"Server returned {response.status_code}.",
|
||||
status=response.status_code,
|
||||
url=url,
|
||||
)
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
if total >= limit:
|
||||
break
|
||||
raw = b"".join(chunks)[:limit]
|
||||
encoding = response.encoding or "utf-8"
|
||||
try:
|
||||
body = raw.decode(encoding, errors="replace")
|
||||
except LookupError:
|
||||
body = raw.decode("utf-8", errors="replace")
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
response_headers = {
|
||||
key: value for key, value in response.headers.items()
|
||||
}
|
||||
return (
|
||||
body,
|
||||
str(response.url),
|
||||
content_type,
|
||||
response.status_code,
|
||||
response_headers,
|
||||
)
|
||||
result = await _once(client, url)
|
||||
if method == "GET" and "html" in result[2]:
|
||||
gate_url = stealth.detect_consent_gate(result[0])
|
||||
if gate_url:
|
||||
try:
|
||||
await client.get(gate_url)
|
||||
result = await _once(client, url)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.debug("consent gate follow-up failed for %s: %s", url, exc)
|
||||
return result
|
||||
except httpx.TimeoutException as exc:
|
||||
raise NetworkError(f"Request timed out fetching {url}", url=url) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
|
||||
Reference in New Issue
Block a user