forked from retoor/devplacepy
feat: restrict backup archive download to primary admin and hide admin-hidden projects from other admins
- Add `get_admin_uids()` and `get_primary_admin_uid()` to database.py for resolving the earliest-created admin - Modify `can_view_project()` in content.py so a project hidden by an admin is invisible to other admins (both web UI and REST API) - Update `_download_url()` and `_backup_payload()` in admin/backups.py to accept a `can_download` flag, gating the download endpoint with `is_primary_admin()` - Remove `role` from `_user_facts()` in docs_live.py to avoid leaking admin status in live docs - Update doc summaries in docs_api.py to reflect the new admin-visibility and backup-download semantics
This commit is contained in:
@@ -88,9 +88,6 @@ LLM_API_KEY = str(
|
||||
if not LLM_API_KEY:
|
||||
LLM_API_KEY = str(uuid.uuid4())
|
||||
|
||||
DEEPSEEK_ENDPOINT = "https://api.deepseek.com/chat/completions"
|
||||
DEEPSEEK_MODEL = "deepseek-v4-flash"
|
||||
|
||||
_BOOT_DT = datetime.now().astimezone()
|
||||
BOOT_DATETIME = _BOOT_DT.isoformat()
|
||||
BOOT_DAY_NAME = _BOOT_DT.strftime("%A")
|
||||
@@ -1047,7 +1044,6 @@ def get_backends() -> list[Backend]:
|
||||
if _backends is None:
|
||||
_backends = [
|
||||
Backend("molodetz", LLM_ENDPOINT, MODEL, LLM_API_KEY, True),
|
||||
Backend("deepseek", DEEPSEEK_ENDPOINT, DEEPSEEK_MODEL, os.getenv("DEEPSEEK_API_KEY"), False),
|
||||
]
|
||||
return _backends
|
||||
|
||||
|
||||
@@ -674,8 +674,6 @@ API_KEY = (
|
||||
or str(uuid.uuid4())
|
||||
)
|
||||
|
||||
DEEPSEEK_ENDPOINT = "https://api.deepseek.com/chat/completions"
|
||||
DEEPSEEK_MODEL = "deepseek-v4-flash"
|
||||
|
||||
_BOOT_DT = datetime.now().astimezone()
|
||||
BOOT_DATETIME = _BOOT_DT.isoformat()
|
||||
@@ -932,7 +930,6 @@ def get_backends() -> list[Backend]:
|
||||
if _backends is None:
|
||||
_backends = [
|
||||
Backend("molodetz", LLM_ENDPOINT, MODEL, API_KEY, True),
|
||||
Backend("deepseek", DEEPSEEK_ENDPOINT, DEEPSEEK_MODEL, os.getenv("DEEPSEEK_API_KEY"), False),
|
||||
]
|
||||
return _backends
|
||||
|
||||
|
||||
@@ -2,16 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
purge,
|
||||
restore,
|
||||
soft_delete,
|
||||
text_search_clause,
|
||||
)
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
from .policy import soft_delete_aware
|
||||
|
||||
@@ -33,10 +27,6 @@ class DbApiError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def column_names(table_name: str) -> list[str]:
|
||||
return list(get_table(table_name).columns)
|
||||
|
||||
@@ -111,66 +101,6 @@ def get_row(table_name: str, key: str, value) -> dict | None:
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def insert_row(table_name: str, data: dict, actor: str) -> dict:
|
||||
table = get_table(table_name)
|
||||
cols = set(column_names(table_name))
|
||||
row = dict(data or {})
|
||||
unknown = [key for key in row if key not in cols]
|
||||
if unknown:
|
||||
raise DbApiError(f"Unknown columns for {table_name}: {', '.join(sorted(unknown))}")
|
||||
if "uid" in cols and not row.get("uid"):
|
||||
row["uid"] = generate_uid()
|
||||
if "created_at" in cols and not row.get("created_at"):
|
||||
row["created_at"] = _now()
|
||||
if soft_delete_aware(table_name):
|
||||
row.setdefault("deleted_at", None)
|
||||
row.setdefault("deleted_by", None)
|
||||
table.insert(row)
|
||||
if row.get("uid"):
|
||||
return get_row(table_name, "uid", row["uid"]) or row
|
||||
return row
|
||||
|
||||
|
||||
def update_row(table_name: str, key: str, value, data: dict, actor: str) -> dict | None:
|
||||
_assert_key(table_name, key)
|
||||
table = get_table(table_name)
|
||||
cols = set(column_names(table_name))
|
||||
if not table.find_one(**{key: value}):
|
||||
return None
|
||||
payload = {k: v for k, v in (data or {}).items() if k not in ("id", "uid", key)}
|
||||
unknown = [k for k in payload if k not in cols]
|
||||
if unknown:
|
||||
raise DbApiError(f"Unknown columns for {table_name}: {', '.join(sorted(unknown))}")
|
||||
if "updated_at" in cols:
|
||||
payload["updated_at"] = _now()
|
||||
payload[key] = value
|
||||
table.update(payload, [key])
|
||||
return get_row(table_name, key, value)
|
||||
|
||||
|
||||
def delete_row(
|
||||
table_name: str, key: str, value, actor: str, *, hard: bool = False
|
||||
) -> dict | None:
|
||||
_assert_key(table_name, key)
|
||||
table = get_table(table_name)
|
||||
row = table.find_one(**{key: value})
|
||||
if not row:
|
||||
return None
|
||||
if soft_delete_aware(table_name) and not hard:
|
||||
soft_delete(table_name, actor, **{key: value})
|
||||
return {"mode": "soft", "row": dict(row)}
|
||||
purge(table_name, **{key: value})
|
||||
return {"mode": "hard", "row": dict(row)}
|
||||
|
||||
|
||||
def restore_row(table_name: str, key: str, value, actor: str) -> dict | None:
|
||||
_assert_key(table_name, key)
|
||||
if not soft_delete_aware(table_name):
|
||||
raise DbApiError(f"{table_name} does not support restore (no soft delete).")
|
||||
restore(table_name, **{key: value})
|
||||
return get_row(table_name, key, value)
|
||||
|
||||
|
||||
def _assert_key(table_name: str, key: str) -> None:
|
||||
if key not in set(column_names(table_name)):
|
||||
raise DbApiError(f"Unknown key column {key!r} for {table_name}.")
|
||||
|
||||
@@ -125,7 +125,7 @@ def validate_select(sql: str, dialect: str = "sqlite") -> Verdict:
|
||||
if not verdict.is_select:
|
||||
verdict.error = (
|
||||
f"Only SELECT queries run through query(); this is a {verdict.statement_type} "
|
||||
"statement. Use the structured /dbapi/{table} CRUD routes to change data."
|
||||
"statement. The database API is read-only and cannot change data."
|
||||
)
|
||||
return verdict
|
||||
ok, error = dry_run(verdict.sql)
|
||||
|
||||
@@ -1593,8 +1593,8 @@ ACTIONS: tuple[Action, ...] = (
|
||||
summary="Run a read-only SQL SELECT and return rows (admin only)",
|
||||
description=(
|
||||
"Executes a SINGLE validated SELECT statement read-only and returns the rows. Only "
|
||||
"SELECT is allowed; INSERT/UPDATE/DELETE/DDL are rejected (use db_insert_row, "
|
||||
"db_update_row, db_delete_row for changes). The response may include a 'suspicious' "
|
||||
"SELECT is allowed; INSERT/UPDATE/DELETE/DDL are rejected. The database API is "
|
||||
"read-only and cannot change data in any way. The response may include a 'suspicious' "
|
||||
"list (e.g. a SELECT with no WHERE/JOIN/LIMIT that scans a whole table); when present, "
|
||||
"surface that warning to the user before trusting the results."
|
||||
),
|
||||
@@ -1638,64 +1638,6 @@ ACTIONS: tuple[Action, ...] = (
|
||||
requires_admin=True,
|
||||
read_only=True,
|
||||
),
|
||||
Action(
|
||||
name="db_insert_row",
|
||||
method="POST",
|
||||
path="/dbapi/{table}",
|
||||
summary="Insert a row into a table (admin only, confirmation required)",
|
||||
description=(
|
||||
"Inserts a new row. Pass the column values as a JSON object string in values_json. "
|
||||
"Soft-delete columns and uid/created_at are filled automatically. Requires confirmation."
|
||||
),
|
||||
params=(
|
||||
path("table", "Table name."),
|
||||
body("values_json", "JSON object of column:value pairs for the new row.", required=True),
|
||||
confirm(),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="db_update_row",
|
||||
method="PATCH",
|
||||
path="/dbapi/{table}/{key}/{value}",
|
||||
summary="Update a row in a table (admin only, confirmation required)",
|
||||
description=(
|
||||
"Updates the row where key equals value. Pass the changed columns as a JSON object "
|
||||
"string in values_json. uid and id cannot be changed. Requires confirmation."
|
||||
),
|
||||
params=(
|
||||
path("table", "Table name."),
|
||||
path("key", "Key column to match (usually 'uid')."),
|
||||
path("value", "Value of the key column."),
|
||||
body("values_json", "JSON object of column:value pairs to change.", required=True),
|
||||
confirm(),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="db_delete_row",
|
||||
method="DELETE",
|
||||
path="/dbapi/{table}/{key}/{value}",
|
||||
summary="Delete a row from a table (admin only, confirmation required)",
|
||||
description=(
|
||||
"Soft-deletes the row where key equals value (restorable). Pass hard=true to "
|
||||
"PERMANENTLY purge it (or for tables without soft delete). Requires confirmation."
|
||||
),
|
||||
params=(
|
||||
path("table", "Table name."),
|
||||
path("key", "Key column to match (usually 'uid')."),
|
||||
path("value", "Value of the key column."),
|
||||
Param(
|
||||
name="hard",
|
||||
location="query",
|
||||
description="Permanently purge instead of soft delete.",
|
||||
required=False,
|
||||
type="boolean",
|
||||
),
|
||||
confirm(),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="gateway_providers",
|
||||
method="GET",
|
||||
|
||||
@@ -53,9 +53,6 @@ CONFIRM_REQUIRED = {
|
||||
"backup_delete",
|
||||
"backup_schedule_delete",
|
||||
"notification_reset",
|
||||
"db_insert_row",
|
||||
"db_update_row",
|
||||
"db_delete_row",
|
||||
"gateway_provider_delete",
|
||||
"gateway_model_delete",
|
||||
}
|
||||
@@ -219,26 +216,6 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
|
||||
f"such as rm, dd, truncate, or drop): {command!r}. Show the user the exact command, get "
|
||||
"explicit confirmation, then call again with confirm=true."
|
||||
)
|
||||
if name == "db_insert_row":
|
||||
table = str(arguments.get("table", "")).strip() or "(unspecified)"
|
||||
return ToolInputError(
|
||||
f"This writes a new row directly into the '{table}' table. Show the user the exact "
|
||||
"table and values, get explicit confirmation, then call again with confirm=true."
|
||||
)
|
||||
if name == "db_update_row":
|
||||
table = str(arguments.get("table", "")).strip() or "(unspecified)"
|
||||
return ToolInputError(
|
||||
f"This updates an existing row in the '{table}' table directly. Show the user the "
|
||||
"exact row and new values, get explicit confirmation, then call again with confirm=true."
|
||||
)
|
||||
if name == "db_delete_row":
|
||||
table = str(arguments.get("table", "")).strip() or "(unspecified)"
|
||||
hard = str(arguments.get("hard", "")).strip().lower() in ("true", "1", "yes", "on")
|
||||
kind = "PERMANENTLY purges" if hard else "soft-deletes"
|
||||
return ToolInputError(
|
||||
f"This {kind} a row in the '{table}' table. Show the user the exact row, get explicit "
|
||||
"confirmation, then call again with confirm=true."
|
||||
)
|
||||
if name in CONFIRM_REQUIRED:
|
||||
return ToolInputError(
|
||||
"This removes the item as a soft delete: it disappears from every surface and is only "
|
||||
@@ -279,7 +256,7 @@ class Dispatcher:
|
||||
self._docs = DocsController(settings, is_admin=is_admin)
|
||||
self._cost = CostController(quota_provider=quota_provider)
|
||||
self._chunks = ChunkController(settings)
|
||||
self._rsearch = RsearchController(settings)
|
||||
self._rsearch = RsearchController(settings, owner_kind, owner_id)
|
||||
from ..container import ContainerController
|
||||
|
||||
self._container = ContainerController(client)
|
||||
|
||||
@@ -36,9 +36,11 @@ class ContainerController:
|
||||
return {"uid": "admin", "username": username or "admin"}
|
||||
|
||||
def _project(self, arguments: dict) -> dict:
|
||||
from devplacepy.content import can_view_project
|
||||
|
||||
slug = str(arguments.get("project_slug", "")).strip()
|
||||
project = resolve_by_slug(get_table("projects"), slug) if slug else None
|
||||
if not project:
|
||||
if not project or not can_view_project(project, self._actor_user()):
|
||||
raise ToolInputError(f"project not found: {slug}")
|
||||
return project
|
||||
|
||||
|
||||
@@ -26,8 +26,12 @@ def _flag(value: Any) -> str:
|
||||
|
||||
|
||||
class RsearchController:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
def __init__(
|
||||
self, settings: Settings, owner_kind: str = "guest", owner_id: str = ""
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if not self._settings.rsearch_enabled:
|
||||
@@ -44,6 +48,13 @@ class RsearchController:
|
||||
return await self._describe(arguments)
|
||||
raise ToolInputError(f"Unknown rsearch tool: {name}")
|
||||
|
||||
def _ledger(self, endpoint: str, success: bool, status_code: int) -> None:
|
||||
from devplacepy.services.openai_gateway.usage import record_rsearch_call
|
||||
|
||||
record_rsearch_call(
|
||||
self._owner_kind, self._owner_id, endpoint, success, status_code
|
||||
)
|
||||
|
||||
async def _request(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
||||
timeout = httpx.Timeout(self._settings.rsearch_timeout_seconds, connect=30.0)
|
||||
@@ -56,9 +67,12 @@ class RsearchController:
|
||||
) as client:
|
||||
response = await client.get(path, params=params)
|
||||
except httpx.TimeoutException as exc:
|
||||
self._ledger(path, False, 0)
|
||||
raise NetworkError(f"rsearch timed out calling {path}", path=path) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
self._ledger(path, False, 0)
|
||||
raise NetworkError(f"rsearch request failed: {exc}", path=path) from exc
|
||||
self._ledger(path, response.status_code < 400, response.status_code)
|
||||
if response.status_code >= 400:
|
||||
raise UpstreamError(
|
||||
f"rsearch returned {response.status_code} for {path}",
|
||||
|
||||
@@ -71,7 +71,9 @@ def _strip_html(raw: str) -> tuple[str, str]:
|
||||
return title, body
|
||||
|
||||
|
||||
async def search_queries(queries: list[str]) -> list[dict]:
|
||||
async def search_queries(
|
||||
queries: list[str], emit: Callable[[dict], None] = lambda frame: None
|
||||
) -> list[dict]:
|
||||
results: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
||||
@@ -85,10 +87,12 @@ async def search_queries(queries: list[str]) -> list[dict]:
|
||||
"/search",
|
||||
params={"query": query, "count": RESULTS_PER_QUERY, "content": "false"},
|
||||
)
|
||||
emit({"type": "rsearch", "endpoint": "/search", "success": response.status_code < 400})
|
||||
if response.status_code >= 400:
|
||||
continue
|
||||
data = response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
emit({"type": "rsearch", "endpoint": "/search", "success": False})
|
||||
logger.warning("deepsearch rsearch failed for %r: %s", query, exc)
|
||||
continue
|
||||
for item in data.get("results") or []:
|
||||
|
||||
@@ -62,7 +62,9 @@ class DeepsearchService(JobService):
|
||||
database.update_deepsearch_session(uid, {"status": "running"})
|
||||
|
||||
try:
|
||||
summary = await self._run_worker(uid, payload_path, output_dir)
|
||||
summary = await self._run_worker(
|
||||
uid, payload_path, output_dir, actor_kind, job.get("owner_id") or ""
|
||||
)
|
||||
except Exception as exc:
|
||||
hub.publish(uid, {"type": "failed", "message": str(exc)[:300]})
|
||||
database.update_deepsearch_session(uid, {"status": "failed"})
|
||||
@@ -161,7 +163,26 @@ class DeepsearchService(JobService):
|
||||
int(entry.get("byte_size") or 0),
|
||||
)
|
||||
|
||||
async def _run_worker(self, uid: str, payload_path: Path, output_dir: Path) -> dict:
|
||||
def _ledger_rsearch(self, owner_kind: str, owner_id: str, frame: dict) -> None:
|
||||
from devplacepy.services.openai_gateway.usage import record_rsearch_call
|
||||
|
||||
success = bool(frame.get("success"))
|
||||
record_rsearch_call(
|
||||
owner_kind,
|
||||
owner_id,
|
||||
frame.get("endpoint") or "/search",
|
||||
success,
|
||||
200 if success else 0,
|
||||
)
|
||||
|
||||
async def _run_worker(
|
||||
self,
|
||||
uid: str,
|
||||
payload_path: Path,
|
||||
output_dir: Path,
|
||||
owner_kind: str = "system",
|
||||
owner_id: str = "",
|
||||
) -> dict:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
@@ -186,6 +207,8 @@ class DeepsearchService(JobService):
|
||||
hub.publish(uid, frame)
|
||||
if frame.get("type") == "report_ready":
|
||||
summary = frame
|
||||
elif frame.get("type") == "rsearch":
|
||||
self._ledger_rsearch(owner_kind, owner_id, frame)
|
||||
elif frame.get("type") == "error":
|
||||
worker_error = frame.get("message", "worker error")
|
||||
err = (await proc.stderr.read()).decode("utf-8", "replace")
|
||||
|
||||
@@ -96,7 +96,7 @@ async def _run(payload: dict, output_dir: Path) -> dict:
|
||||
_emit({"type": "queries", "queries": queries})
|
||||
|
||||
_emit({"type": "stage", "stage": "searching", "message": "Searching the web"})
|
||||
candidates = await search_queries(queries)
|
||||
candidates = await search_queries(queries, _emit)
|
||||
_emit({"type": "candidates", "count": len(candidates)})
|
||||
|
||||
_emit({"type": "stage", "stage": "crawling", "message": "Crawling sources"})
|
||||
|
||||
@@ -130,9 +130,13 @@ def build_analytics(
|
||||
) -> dict:
|
||||
if GATEWAY_LEDGER not in db.tables:
|
||||
return empty_payload(hours)
|
||||
hours = max(1, min(hours, MAX_WINDOW_HOURS))
|
||||
now = _now()
|
||||
cutoff = _iso(now - timedelta(hours=hours))
|
||||
if hours <= 0:
|
||||
hours = 0
|
||||
cutoff = "0000-01-01T00:00:00+00:00"
|
||||
else:
|
||||
hours = max(1, min(hours, MAX_WINDOW_HOURS))
|
||||
cutoff = _iso(now - timedelta(hours=hours))
|
||||
rows = _ledger_rows(cutoff)
|
||||
if not rows:
|
||||
return empty_payload(hours)
|
||||
|
||||
@@ -27,6 +27,7 @@ PRICE_OUTPUT_PER_M_DEFAULT = 0.28
|
||||
VISION_PRICE_INPUT_PER_M_DEFAULT = 0.0
|
||||
VISION_PRICE_OUTPUT_PER_M_DEFAULT = 0.0
|
||||
EMBED_PRICE_INPUT_PER_M_DEFAULT = 0.01
|
||||
RSEARCH_COST_PER_CALL_DEFAULT = 0.0
|
||||
|
||||
USAGE_RETENTION_HOURS_DEFAULT = 720
|
||||
|
||||
|
||||
@@ -267,6 +267,15 @@ class GatewayService(BaseService):
|
||||
help="Fallback only; used when the embeddings upstream returns no native cost.",
|
||||
group="Pricing",
|
||||
),
|
||||
ConfigField(
|
||||
"gateway_rsearch_cost_per_call",
|
||||
"rsearch cost / call ($)",
|
||||
type="float",
|
||||
default=config.RSEARCH_COST_PER_CALL_DEFAULT,
|
||||
minimum=0,
|
||||
help="Flat cost attributed to each external rsearch call (web search / AI answer / chat / image describe), recorded under backend 'rsearch' so external AI spend appears in AI usage.",
|
||||
group="Pricing",
|
||||
),
|
||||
ConfigField(
|
||||
"gateway_max_retries",
|
||||
"Max retries",
|
||||
|
||||
@@ -313,6 +313,90 @@ class GatewayUsageLedger:
|
||||
logger.warning("gateway usage record failed: %s", exc)
|
||||
return None
|
||||
|
||||
def record_external(
|
||||
self,
|
||||
*,
|
||||
owner_kind: str,
|
||||
owner_id: str,
|
||||
backend: str,
|
||||
endpoint: str,
|
||||
model: str,
|
||||
cost_usd: float,
|
||||
success: bool,
|
||||
status_code: int,
|
||||
latency_ms: float = 0.0,
|
||||
) -> Optional[dict]:
|
||||
try:
|
||||
row = {
|
||||
"created_at": _iso(_now()),
|
||||
"owner_kind": owner_kind or "unknown",
|
||||
"owner_id": owner_id or "unknown",
|
||||
"backend": backend,
|
||||
"endpoint": endpoint or "",
|
||||
"requested_model": model or "",
|
||||
"model": model or "",
|
||||
"status_code": int(status_code or 0),
|
||||
"success": 1 if success else 0,
|
||||
"error_category": None,
|
||||
"upstream_latency_ms": float(latency_ms or 0),
|
||||
"gateway_overhead_ms": 0.0,
|
||||
"queue_wait_ms": 0.0,
|
||||
"connect_ms": 0.0,
|
||||
"total_latency_ms": float(latency_ms or 0),
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"cache_hit_tokens": 0,
|
||||
"cache_miss_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"tokens_per_second": 0.0,
|
||||
"context_window": None,
|
||||
"context_utilization": None,
|
||||
"cost_usd": round(float(cost_usd or 0), 8),
|
||||
"input_cost_usd": 0.0,
|
||||
"output_cost_usd": 0.0,
|
||||
"native_cost": 0,
|
||||
"stream_requested": 0,
|
||||
"temperature": None,
|
||||
"top_p": None,
|
||||
"max_tokens": None,
|
||||
"has_tools": 0,
|
||||
"retries_attempted": 0,
|
||||
"retry_succeeded": 0,
|
||||
"circuit_open": 0,
|
||||
"user_agent": "",
|
||||
}
|
||||
get_table(GATEWAY_LEDGER).insert(row)
|
||||
self._audit_external(row)
|
||||
return row
|
||||
except Exception as exc:
|
||||
logger.warning("gateway external usage record failed: %s", exc)
|
||||
return None
|
||||
|
||||
def _audit_external(self, row: dict) -> None:
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
owner_kind = row.get("owner_kind") or "unknown"
|
||||
owner_id = row.get("owner_id") or "unknown"
|
||||
actor_kind, actor_uid, actor_role = audit_actor_for(owner_kind, owner_id)
|
||||
audit.record_system(
|
||||
"ai.gateway.call",
|
||||
actor_kind=actor_kind,
|
||||
actor_uid=actor_uid,
|
||||
actor_role=actor_role,
|
||||
origin="api",
|
||||
result="success" if row.get("success") else "failure",
|
||||
summary=f"external AI call by {owner_kind}/{owner_id} ({row.get('backend')})",
|
||||
metadata={
|
||||
"backend": row.get("backend"),
|
||||
"endpoint": row.get("endpoint"),
|
||||
"cost_usd": row.get("cost_usd"),
|
||||
"status_code": row.get("status_code"),
|
||||
"owner_kind": owner_kind,
|
||||
"owner_id": owner_id,
|
||||
},
|
||||
)
|
||||
|
||||
def _audit(self, raw: dict, norm: dict, cost_usd: float) -> None:
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
@@ -365,3 +449,26 @@ class GatewayUsageLedger:
|
||||
get_table(GATEWAY_CONCURRENCY).delete(created_at={"<": cutoff})
|
||||
)
|
||||
return ledger_removed, samples_removed
|
||||
|
||||
|
||||
def record_rsearch_call(
|
||||
owner_kind: str, owner_id: str, endpoint: str, success: bool, status_code: int
|
||||
) -> None:
|
||||
try:
|
||||
from devplacepy.services.manager import service_manager
|
||||
|
||||
service = service_manager.get_service("openai")
|
||||
cfg = service.get_config() if service is not None else {}
|
||||
cost = float(cfg.get("gateway_rsearch_cost_per_call", 0.0) or 0.0)
|
||||
GatewayUsageLedger().record_external(
|
||||
owner_kind=owner_kind or "system",
|
||||
owner_id=owner_id or "",
|
||||
backend="rsearch",
|
||||
endpoint=endpoint or "/search",
|
||||
model="rsearch",
|
||||
cost_usd=cost,
|
||||
success=success,
|
||||
status_code=status_code,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("rsearch usage ledger failed: %s", exc)
|
||||
|
||||
Reference in New Issue
Block a user