forked from retoor/devplacepy
feat: add project file system with CRUD, upload, inline editing, and video attachment support
- Add new `/projects/{slug}/files` endpoint group for per-project filesystem operations including directory and file CRUD, upload, and inline editing with public read and owner write access
- Extend attachment system to support video formats (webm, ogv, mov, m4v) with proper file icons and MIME types
- Implement configurable allowed file types via `allowed_file_types` site setting, replacing hardcoded `ALLOWED_UPLOAD_TYPES` with dynamic `allowed_extensions()` and `is_extension_allowed()` functions
- Add `delete_all_project_files()` call in `delete_content_item()` to clean up project files when a project is deleted
- Create database indexes on `project_files` table for `(project_uid, path)` and `(project_uid, parent_path)` to optimize file lookups
- Introduce `docs_prose.py` module with `render_prose()` function that renders Markdown content inside `data-render` divs using mistune, enabling dynamic prose rendering in documentation pages
- Enhance docs search with Markdown-aware text stripping (`_demarkdown()`) and improved HTML/script/style sanitization for better search indexing
- Update documentation API samples to reflect new attachment response fields (`is_image`, `is_video`, `mime_type`) and note video format support
- Update README to document the new project files endpoint and clarify AI gateway attribution for guest Devii sessions
This commit is contained in:
@@ -313,6 +313,95 @@ def _hourly(rows: list[dict], first_hour: dict) -> list[dict]:
|
||||
return sorted(buckets.values(), key=lambda b: b["hour"], reverse=True)
|
||||
|
||||
|
||||
def empty_user_usage(owner_id: str, hours: int = 24) -> dict:
|
||||
return {
|
||||
"owner_id": owner_id,
|
||||
"window_hours": hours,
|
||||
"generated_at": _iso(_now()),
|
||||
"requests": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"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},
|
||||
"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)"},
|
||||
}
|
||||
|
||||
|
||||
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["requests"] += 1
|
||||
bucket["cost_usd"] += float(r.get("cost_usd") or 0)
|
||||
bucket["total_tokens"] += int(r.get("total_tokens") or 0)
|
||||
out = sorted(buckets.values(), key=lambda b: b["hour"])
|
||||
for bucket in out:
|
||||
bucket["cost_usd"] = round(bucket["cost_usd"], 6)
|
||||
return out
|
||||
|
||||
|
||||
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,
|
||||
))
|
||||
if not rows:
|
||||
return empty_user_usage(owner_id, hours)
|
||||
|
||||
requests = len(rows)
|
||||
success = sum(int(r.get("success") or 0) for r in rows)
|
||||
failed = requests - success
|
||||
total_cost = sum(float(r.get("cost_usd") or 0) for r in rows)
|
||||
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)
|
||||
total_tokens = sum(int(r.get("total_tokens") or 0) for r in rows)
|
||||
latencies = _positive(rows, "upstream_latency_ms")
|
||||
avg_latency = round(sum(latencies) / len(latencies), 1) if latencies else 0.0
|
||||
tps = _positive(rows, "tokens_per_second")
|
||||
avg_tps = round(sum(tps) / len(tps), 2) if tps else 0.0
|
||||
cost_per_hour = total_cost / hours
|
||||
cost_per_request = total_cost / requests if requests else 0.0
|
||||
|
||||
return {
|
||||
"owner_id": owner_id,
|
||||
"window_hours": hours,
|
||||
"generated_at": _iso(now),
|
||||
"requests": requests,
|
||||
"success": success,
|
||||
"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},
|
||||
"cost": {
|
||||
"window_usd": round(total_cost, 6),
|
||||
"per_hour_usd": round(cost_per_hour, 6),
|
||||
"per_request_usd": round(cost_per_request, 6),
|
||||
"projected_30d_usd": round(cost_per_hour * 24 * 30, 2),
|
||||
},
|
||||
"latency": {"avg_ms": avg_latency, "avg_tps": avg_tps},
|
||||
"first_used": rows[0]["created_at"],
|
||||
"last_used": rows[-1]["created_at"],
|
||||
"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)"},
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
UPSTREAM_URL_DEFAULT = "https://api.deepseek.com/chat/completions"
|
||||
MODEL_DEFAULT = "deepseek-chat"
|
||||
TIMEOUT_DEFAULT = 180
|
||||
MODEL_DEFAULT = "deepseek-v4-flash"
|
||||
TIMEOUT_DEFAULT = 300
|
||||
TIMEOUT_MIN = 300
|
||||
INSTANCES_DEFAULT = 4
|
||||
|
||||
VISION_URL_DEFAULT = "https://openrouter.ai/api/v1/chat/completions"
|
||||
@@ -27,7 +28,9 @@ CIRCUIT_THRESHOLD_DEFAULT = 5
|
||||
CIRCUIT_COOLDOWN_SECONDS_DEFAULT = 30
|
||||
|
||||
MODEL_CONTEXT_MAP_DEFAULT = {
|
||||
"deepseek-chat": 65536,
|
||||
"deepseek-reasoner": 65536,
|
||||
"deepseek-v4-flash": 1_048_576,
|
||||
"deepseek-v4-pro": 1_048_576,
|
||||
"deepseek-chat": 1_048_576,
|
||||
"deepseek-reasoner": 1_048_576,
|
||||
"google/gemma-3-12b-it": 8192,
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ class GatewayService(BaseService):
|
||||
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=1,
|
||||
help="Per-request upstream timeout.", 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).",
|
||||
@@ -68,8 +68,10 @@ class GatewayService(BaseService):
|
||||
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=False,
|
||||
help="Any authenticated user 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"),
|
||||
|
||||
@@ -129,7 +129,7 @@ class VisionAugmenter:
|
||||
headers["X-Title"] = self.title
|
||||
start = time.monotonic()
|
||||
try:
|
||||
resp = await client.post(self.vision_url, json=payload, headers=headers, timeout=120.0)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user