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:
@@ -9,6 +9,9 @@ MODEL_DEFAULT = INTERNAL_MODEL
|
||||
INPUT_COST_PER_1M_DEFAULT = 0.27
|
||||
OUTPUT_COST_PER_1M_DEFAULT = 1.10
|
||||
|
||||
COST_WINDOW_SECONDS = 600
|
||||
COST_WARMUP_SECONDS = 120
|
||||
|
||||
STATE_DIR = Path.home() / ".devplace_bots"
|
||||
ARTICLE_REGISTRY_PATH = Path.home() / ".dpbot_article_registry.json"
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from devplacepy.services.base import BaseService, ConfigField
|
||||
from devplacepy.services.bot import config
|
||||
@@ -50,8 +51,8 @@ class BotsService(BaseService):
|
||||
super().__init__(name="bots", interval_seconds=15)
|
||||
self._fleet = {}
|
||||
self._state_dir = config.STATE_DIR
|
||||
self._cost_baseline = None
|
||||
self._cost_baseline_at = None
|
||||
self._cost_samples = deque()
|
||||
self._cost_anchor = None
|
||||
|
||||
def _resolve_api_key(self, cfg: dict) -> str:
|
||||
from devplacepy.database import internal_gateway_key
|
||||
@@ -184,23 +185,17 @@ class BotsService(BaseService):
|
||||
calls,
|
||||
f"${cost:.4f}",
|
||||
])
|
||||
now = datetime.now(timezone.utc)
|
||||
if self._started_at is None:
|
||||
self._cost_baseline = None
|
||||
self._cost_baseline_at = None
|
||||
elif self._cost_baseline is None or self._cost_baseline[0] != self._started_at:
|
||||
self._cost_baseline = (self._started_at, total_cost)
|
||||
self._cost_baseline_at = now
|
||||
window = (now - self._cost_baseline_at).total_seconds() if self._cost_baseline_at else 0.0
|
||||
observed_cost = max(0.0, total_cost - self._cost_baseline[1]) if self._cost_baseline else 0.0
|
||||
cost_per_hour = observed_cost / window * 3600 if window > 0 else 0.0
|
||||
projected_24h = observed_cost / window * 86400 if window > 0 else 0.0
|
||||
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"
|
||||
projected_value = f"${projected_24h:.2f}" if ready else "warming up"
|
||||
stats = [
|
||||
{"label": "Bots running", "value": running},
|
||||
{"label": "Fleet cost", "value": f"${total_cost:.4f}"},
|
||||
{"label": "Observed window", "value": f"{window:.0f}s"},
|
||||
{"label": "Cost rate", "value": f"${cost_per_hour:.4f}/h"},
|
||||
{"label": "Projected 24h cost", "value": f"${projected_24h:.2f}"},
|
||||
{"label": "Cost rate", "value": rate_value},
|
||||
{"label": "Projected 24h cost", "value": projected_value},
|
||||
{"label": "LLM calls", "value": total_calls},
|
||||
{"label": "Tokens in", "value": total_in},
|
||||
{"label": "Tokens out", "value": total_out},
|
||||
@@ -213,3 +208,21 @@ class BotsService(BaseService):
|
||||
"rows": rows,
|
||||
}
|
||||
return {"stats": stats, "table": table}
|
||||
|
||||
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
|
||||
return 0.0, 0.0, False
|
||||
if self._cost_anchor != self._started_at:
|
||||
self._cost_samples.clear()
|
||||
self._cost_anchor = self._started_at
|
||||
self._cost_samples.append((now, total_cost))
|
||||
cutoff = now - timedelta(seconds=config.COST_WINDOW_SECONDS)
|
||||
while len(self._cost_samples) > 2 and self._cost_samples[0][0] < cutoff:
|
||||
self._cost_samples.popleft()
|
||||
start_at, start_cost = self._cost_samples[0]
|
||||
window = (now - start_at).total_seconds()
|
||||
observed_cost = max(0.0, total_cost - start_cost)
|
||||
ready = window >= config.COST_WARMUP_SECONDS
|
||||
return window, observed_cost, ready
|
||||
|
||||
@@ -205,6 +205,81 @@ ACTIONS: tuple[Action, ...] = (
|
||||
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."),),
|
||||
),
|
||||
Action(
|
||||
name="project_list_files",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/files",
|
||||
summary="List every file and directory in a project filesystem",
|
||||
description="Returns the flat list of nodes (path, type, size, mime). Use it to inspect the project tree before reading or writing files.",
|
||||
params=(path("project_slug", "Project slug or uid."),),
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="project_read_file",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/files/raw",
|
||||
summary="Read one file from a project filesystem",
|
||||
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),
|
||||
),
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="project_write_file",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/write",
|
||||
summary="Create or overwrite a text file in a project (parent directories are created automatically)",
|
||||
description="The primary tool for building a project: write any text file by path. Missing parent directories are created recursively.",
|
||||
params=(
|
||||
path("project_slug", "Project slug or uid."),
|
||||
body("path", "Relative file path, e.g. src/app/main.py.", required=True),
|
||||
body("content", "Full file content.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="project_upload_file",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/upload",
|
||||
summary="Upload a local file into a project directory (parents created automatically)",
|
||||
params=(
|
||||
path("project_slug", "Project slug or uid."),
|
||||
upload("file", "Local filesystem path of the file to upload."),
|
||||
body("path", "Target directory inside the project, empty for the root."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="project_make_dir",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/mkdir",
|
||||
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),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="project_move_file",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/move",
|
||||
summary="Move or rename a file or directory within a project",
|
||||
params=(
|
||||
path("project_slug", "Project slug or uid."),
|
||||
body("from_path", "Existing path.", required=True),
|
||||
body("to_path", "New path.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="project_delete_file",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/delete",
|
||||
summary="Delete a file or directory (recursive) from a project filesystem",
|
||||
params=(
|
||||
path("project_slug", "Project slug or uid."),
|
||||
body("path", "Relative path to delete.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="search_users",
|
||||
method="GET",
|
||||
@@ -518,6 +593,7 @@ ACTIONS: tuple[Action, ...] = (
|
||||
"instead of paging through admin_list_users."
|
||||
),
|
||||
params=(query("top_n", "How many top authors to include (1-50)."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="ai_usage",
|
||||
@@ -535,6 +611,7 @@ ACTIONS: tuple[Action, ...] = (
|
||||
query("hours", "Lookback window in hours (1-168, default 48)."),
|
||||
query("top_n", "How many rows in each top-N breakdown (default 10)."),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_list_users",
|
||||
|
||||
@@ -6,17 +6,32 @@ from .spec import Action
|
||||
|
||||
COST_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="cost_stats",
|
||||
name="usage_quota",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Report token usage and cost statistics for the current session",
|
||||
summary="Report the current user's AI usage as a percentage of their rolling 24h quota",
|
||||
description=(
|
||||
"Returns this session's LLM token counts (prompt, completion, total, cache hit/miss, "
|
||||
"reasoning), the cost in USD broken down by cache-hit input, cache-miss input, and "
|
||||
"output, per-request averages, cache hit rate, and session timing. Costs are priced "
|
||||
"as DeepSeek V4 Flash, the cheapest official DeepSeek model."
|
||||
"Returns ONLY the percentage of the rolling 24-hour AI quota the current user has "
|
||||
"used, the number of turns taken today, and whether the limit is reached. It never "
|
||||
"returns any cost, dollar amount, pricing, or spend figure. Use this for any "
|
||||
"'how much have I used' or 'what percent of resources' question."
|
||||
),
|
||||
handler="cost",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="cost_stats",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Report token usage and full USD cost statistics for the current session (administrators only)",
|
||||
description=(
|
||||
"Administrators only. Returns this session's LLM token counts (prompt, completion, "
|
||||
"total, cache hit/miss, reasoning), the cost in USD broken down by cache-hit input, "
|
||||
"cache-miss input, and output, per-request averages, cache hit rate, and session "
|
||||
"timing. Financial figures must never be shown to non-admin users."
|
||||
),
|
||||
handler="cost",
|
||||
requires_auth=True,
|
||||
requires_admin=True,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -43,6 +43,8 @@ class Dispatcher:
|
||||
agentic: AgenticController,
|
||||
avatar: AvatarController | None = None,
|
||||
browser: Any = None,
|
||||
is_admin: bool = False,
|
||||
quota_provider: Any = None,
|
||||
) -> None:
|
||||
self._actions = catalog.by_name()
|
||||
self._client = client
|
||||
@@ -51,9 +53,10 @@ class Dispatcher:
|
||||
self._agentic = agentic
|
||||
self._avatar = avatar
|
||||
self._browser = browser
|
||||
self._is_admin = is_admin
|
||||
self._fetch = FetchController(settings)
|
||||
self._docs = DocsController(settings)
|
||||
self._cost = CostController()
|
||||
self._cost = CostController(quota_provider=quota_provider)
|
||||
self._chunks = ChunkController(settings)
|
||||
self._rsearch = RsearchController(settings)
|
||||
|
||||
@@ -69,6 +72,11 @@ class Dispatcher:
|
||||
"Not authenticated. Ask the user for credentials and call the login tool first.",
|
||||
tool=name,
|
||||
)
|
||||
if action.requires_admin and not self._is_admin:
|
||||
raise AuthRequiredError(
|
||||
"This information is restricted to administrators.",
|
||||
tool=name,
|
||||
)
|
||||
resource_key = self._resource_key(action, arguments)
|
||||
if resource_key:
|
||||
cached = serve_resource(resource_key, self._settings.max_response_chars)
|
||||
|
||||
@@ -26,6 +26,7 @@ class Action:
|
||||
description: str = ""
|
||||
params: tuple[Param, ...] = ()
|
||||
requires_auth: bool = True
|
||||
requires_admin: bool = False
|
||||
handler: Literal[
|
||||
"http", "login", "logout", "status", "task", "agentic", "avatar", "client", "fetch",
|
||||
"docs", "cost", "chunks", "rsearch"
|
||||
@@ -82,9 +83,10 @@ 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) -> 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
|
||||
if authenticated or not action.requires_auth
|
||||
if (authenticated or not action.requires_auth)
|
||||
and (is_admin or not action.requires_admin)
|
||||
]
|
||||
|
||||
@@ -89,19 +89,20 @@ SYSTEM_PROMPT = (
|
||||
"so they see the new state immediately, then confirm the change. Do not reload pages "
|
||||
"unrelated to the change.\n\n"
|
||||
"METRICS AND COST\n"
|
||||
"When asked about cost, usage, or service metrics, read the live values from the service "
|
||||
"data tools and report the figures those tools already compute - including Fleet cost, "
|
||||
"Cost rate, and Projected 24h cost. Never substitute or recompute cost from external or "
|
||||
"public provider pricing; the configured per-token rates are already applied. The Projected "
|
||||
"24h cost is extrapolated from the Observed window since the service started - report it as "
|
||||
"an estimate and state the observed window it is based on rather than presenting it as a "
|
||||
"fact.\n\n"
|
||||
"Monetary figures (USD cost, cost rate, projected cost, spend, and per-token pricing) are "
|
||||
"administrator-only. For any 'how much have I used' or resource question, call usage_quota "
|
||||
"and report only the percentage of the rolling 24h quota used and the turn count - never a "
|
||||
"dollar amount. Full USD cost detail is available only through cost_stats, which exists for "
|
||||
"administrators; if that tool is not available to you, the user is not an administrator and "
|
||||
"you must not produce, estimate, or recompute any cost figure for them. Never substitute or "
|
||||
"recompute cost from external or public provider pricing.\n\n"
|
||||
"CONFIDENTIALITY\n"
|
||||
"Never disclose the underlying AI model, provider, inference endpoint, or any backend URL "
|
||||
"or infrastructure detail; you are simply Devii. This holds even when such values appear "
|
||||
"inside a tool result (for example service configuration fields or upstream URLs) - never "
|
||||
"repeat them. If asked, say you do not share that. When reporting cost or usage, omit the "
|
||||
"model name and provider."
|
||||
"repeat them. If asked, say you do not share that. Never disclose any monetary cost, dollar "
|
||||
"amount, or pricing to a non-administrator; report their usage only as a percentage of "
|
||||
"their quota. When reporting usage, omit the model name and provider."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any
|
||||
|
||||
logger = logging.getLogger("devii.agentic.compaction")
|
||||
|
||||
SUMMARY_INPUT_CAP = 120_000
|
||||
SUMMARY_INPUT_CAP = 600_000
|
||||
SUMMARY_PROMPT = (
|
||||
"Summarize the following assistant conversation segment as a concise factual log of "
|
||||
"actions taken, tools called, entities created or changed, conclusions reached, and "
|
||||
|
||||
@@ -17,7 +17,7 @@ from .state import AgentState, reset_state, set_state
|
||||
logger = logging.getLogger("devii.agentic.loop")
|
||||
|
||||
TraceCallback = Callable[[str, str, str], None]
|
||||
OUTPUT_CAP_CHARS = 200_000
|
||||
OUTPUT_CAP_CHARS = 400_000
|
||||
|
||||
REFLECTION_TRIGGER = (
|
||||
"[reflection-trigger] One or more tool calls returned an error. Call reflect() with the "
|
||||
|
||||
@@ -11,8 +11,8 @@ from typing import Optional
|
||||
|
||||
logger = logging.getLogger("devii.chunks")
|
||||
|
||||
STORE_MAX_ENTRIES = 24
|
||||
STORE_MAX_CHARS = 2_000_000
|
||||
STORE_MAX_ENTRIES = 16
|
||||
STORE_MAX_CHARS = 8_000_000
|
||||
|
||||
_active_store: contextvars.ContextVar = contextvars.ContextVar("devii_chunk_store", default=None)
|
||||
|
||||
|
||||
@@ -147,8 +147,8 @@ 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)
|
||||
tools = CATALOG.tool_schemas_for(client.authenticated)
|
||||
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,
|
||||
|
||||
@@ -12,17 +12,25 @@ from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
|
||||
DEFAULT_AI_URL = INTERNAL_GATEWAY_URL
|
||||
DEFAULT_AI_MODEL = INTERNAL_MODEL
|
||||
DEFAULT_BASE_URL = "http://127.0.0.1:10500"
|
||||
DEFAULT_TIMEOUT_SECONDS = 45.0
|
||||
DEFAULT_MAX_RESPONSE_CHARS = 12000
|
||||
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
|
||||
|
||||
MIN_TIMEOUT_SECONDS = 300.0
|
||||
DEFAULT_TIMEOUT_SECONDS = 300.0
|
||||
DEFAULT_MAX_RESPONSE_CHARS = 200_000
|
||||
DEFAULT_MAX_TOOL_ITERATIONS = 40
|
||||
DEFAULT_DELEGATE_MAX_ITERATIONS = 25
|
||||
DEFAULT_CONTEXT_COMPACT_THRESHOLD = 220_000
|
||||
DEFAULT_CONTEXT_COMPACT_THRESHOLD = CONTEXT_INPUT_BUDGET_TOKENS * CHARS_PER_TOKEN
|
||||
DEFAULT_CONTEXT_KEEP_TAIL = 12
|
||||
DEFAULT_RECALL_TOP_K = 3
|
||||
DEFAULT_FETCH_MAX_CHARS = 24_000
|
||||
DEFAULT_FETCH_TIMEOUT_SECONDS = 30.0
|
||||
DEFAULT_FETCH_MAX_BYTES = 5_000_000
|
||||
DEFAULT_FETCH_MAX_CHARS = 200_000
|
||||
DEFAULT_FETCH_TIMEOUT_SECONDS = 300.0
|
||||
DEFAULT_FETCH_MAX_BYTES = 8_000_000
|
||||
DEFAULT_RSEARCH_URL = "https://rsearch.app.molodetz.nl"
|
||||
DEFAULT_RSEARCH_TIMEOUT_SECONDS = 300.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -55,6 +63,8 @@ class Settings:
|
||||
allow_eval: bool
|
||||
rsearch_enabled: bool
|
||||
rsearch_url: str
|
||||
rsearch_timeout_seconds: float
|
||||
daily_limit_usd: float
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
@@ -94,6 +104,10 @@ def load_settings() -> Settings:
|
||||
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)
|
||||
),
|
||||
daily_limit_usd=0.0,
|
||||
)
|
||||
|
||||
|
||||
@@ -110,19 +124,24 @@ FIELD_PRICE_CACHE_HIT = "devii_price_cache_hit"
|
||||
FIELD_PRICE_CACHE_MISS = "devii_price_cache_miss"
|
||||
FIELD_PRICE_OUTPUT = "devii_price_output"
|
||||
FIELD_ALLOW_EVAL = "devii_allow_eval"
|
||||
FIELD_TIMEOUT = "devii_timeout"
|
||||
FIELD_FETCH_TIMEOUT = "devii_fetch_timeout"
|
||||
FIELD_RSEARCH_ENABLED = "devii_rsearch_enabled"
|
||||
FIELD_RSEARCH_URL = "devii_rsearch_url"
|
||||
FIELD_RSEARCH_TIMEOUT = "devii_rsearch_timeout"
|
||||
|
||||
LESSONS_DB_PATH = os.environ.get("DEVII_LESSONS_DB", "devii_lessons.db")
|
||||
|
||||
|
||||
def build_settings(config: dict, base_url: str, api_key: str) -> Settings:
|
||||
def build_settings(config: dict, base_url: str, api_key: str, owner_kind: str = "guest") -> 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
|
||||
return Settings(
|
||||
ai_url=config[FIELD_AI_URL] or DEFAULT_AI_URL,
|
||||
ai_key=config[FIELD_AI_KEY] or str(uuid.uuid4()),
|
||||
ai_key=ai_key,
|
||||
ai_model=config[FIELD_AI_MODEL] or DEFAULT_AI_MODEL,
|
||||
base_url=(base_url or DEFAULT_BASE_URL).rstrip("/"),
|
||||
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
|
||||
timeout_seconds=float(config.get(FIELD_TIMEOUT) or DEFAULT_TIMEOUT_SECONDS),
|
||||
max_response_chars=DEFAULT_MAX_RESPONSE_CHARS,
|
||||
max_tool_iterations=int(config[FIELD_MAX_ITERATIONS]),
|
||||
log_level="INFO",
|
||||
@@ -140,10 +159,15 @@ def build_settings(config: dict, base_url: str, api_key: str) -> Settings:
|
||||
login_email="",
|
||||
login_password="",
|
||||
fetch_max_chars=DEFAULT_FETCH_MAX_CHARS,
|
||||
fetch_timeout_seconds=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),
|
||||
daily_limit_usd=float(
|
||||
(config.get(FIELD_GUEST_DAILY_USD, 0.05) if owner_kind == "guest"
|
||||
else config.get(FIELD_USER_DAILY_USD, 1.0)) or 0.0
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3,19 +3,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from ..errors import ToolInputError
|
||||
from .tracker import get_tracker
|
||||
|
||||
|
||||
class CostController:
|
||||
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 != "cost_stats":
|
||||
raise ToolInputError(f"Unknown cost tool: {name}")
|
||||
tracker = get_tracker()
|
||||
if tracker is None:
|
||||
return json.dumps(
|
||||
{"status": "unavailable", "message": "No cost tracker is active for this session."}
|
||||
)
|
||||
return json.dumps({"status": "success", **tracker.stats()}, ensure_ascii=False)
|
||||
if name == "usage_quota":
|
||||
if self._quota_provider is None:
|
||||
return json.dumps(
|
||||
{"status": "unavailable", "message": "No quota information is available for this session."}
|
||||
)
|
||||
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."}
|
||||
)
|
||||
return json.dumps({"status": "success", **tracker.stats()}, ensure_ascii=False)
|
||||
raise ToolInputError(f"Unknown cost tool: {name}")
|
||||
|
||||
@@ -17,7 +17,7 @@ from .tasks.store import TaskStore, memory_db
|
||||
|
||||
logger = logging.getLogger("devii.hub")
|
||||
|
||||
SettingsBuilder = Callable[[str, str], "tuple[Settings, Pricing]"]
|
||||
SettingsBuilder = Callable[[str, str, str], "tuple[Settings, Pricing]"]
|
||||
|
||||
|
||||
class DeviiHub:
|
||||
@@ -35,20 +35,22 @@ 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)
|
||||
session = self._sessions.get(key)
|
||||
if session is not None:
|
||||
return session
|
||||
settings, pricing = self._build(api_key, base_url)
|
||||
settings, pricing = self._build(api_key, base_url, owner_kind)
|
||||
llm = LLMClient(settings)
|
||||
# Persistent, owner-isolated stores for signed-in users; ephemeral in-memory for guests.
|
||||
owned_db = db if owner_kind == "user" else memory_db()
|
||||
task_store = TaskStore(owned_db, owner_kind, owner_id)
|
||||
lessons = LessonStore(owned_db, owner_kind, owner_id)
|
||||
session = DeviiSession(
|
||||
owner_kind, owner_id, username, settings, llm, lessons, pricing, task_store, self._stores
|
||||
owner_kind, owner_id, username, settings, llm, lessons, pricing, task_store,
|
||||
self._stores, is_admin=is_admin,
|
||||
)
|
||||
if owner_kind == "user":
|
||||
saved = self._stores["conversations"].load(owner_kind, owner_id)
|
||||
|
||||
@@ -43,12 +43,13 @@ class RsearchController:
|
||||
|
||||
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)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self._settings.rsearch_url,
|
||||
headers=headers,
|
||||
follow_redirects=True,
|
||||
timeout=self._settings.fetch_timeout_seconds,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
response = await client.get(path, params=params)
|
||||
except httpx.TimeoutException as exc:
|
||||
|
||||
@@ -38,6 +38,15 @@ class DeviiService(BaseService):
|
||||
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,
|
||||
@@ -69,6 +78,10 @@ class DeviiService(BaseService):
|
||||
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):
|
||||
@@ -104,9 +117,9 @@ class DeviiService(BaseService):
|
||||
output_per_m=float(cfg[config.FIELD_PRICE_OUTPUT]),
|
||||
)
|
||||
|
||||
def _build_settings(self, api_key: str, base_url: str):
|
||||
def _build_settings(self, api_key: str, base_url: str, owner_kind: str = "guest"):
|
||||
cfg = self.effective_config()
|
||||
settings = build_settings(cfg, base_url, api_key)
|
||||
settings = build_settings(cfg, base_url, api_key, owner_kind)
|
||||
return settings, self._pricing(cfg)
|
||||
|
||||
def daily_limit_for(self, owner_kind: str) -> float:
|
||||
|
||||
@@ -54,11 +54,13 @@ class DeviiSession:
|
||||
pricing: Pricing,
|
||||
task_store: TaskStore,
|
||||
stores: dict[str, Any],
|
||||
is_admin: bool = False,
|
||||
) -> None:
|
||||
self.owner_kind = owner_kind
|
||||
self.owner_id = owner_id
|
||||
self.username = username
|
||||
self.settings = settings
|
||||
self.is_admin = is_admin
|
||||
self.persist_conversation = owner_kind == "user"
|
||||
self._llm = llm
|
||||
self._lessons = lessons
|
||||
@@ -73,8 +75,10 @@ class DeviiSession:
|
||||
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,
|
||||
)
|
||||
self.tools = CATALOG.tool_schemas_for(self.client.authenticated)
|
||||
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,
|
||||
@@ -82,6 +86,7 @@ class DeviiSession:
|
||||
self.agent = Agent(
|
||||
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
|
||||
@@ -90,7 +95,8 @@ class DeviiSession:
|
||||
self._ledger = stores["ledger"]
|
||||
self._audit = stores["turns"]
|
||||
self._conns: set[Any] = set()
|
||||
self._primary: Any = None
|
||||
self._conn_meta: dict[Any, dict[str, Any]] = {}
|
||||
self._attach_seq = 0
|
||||
self._lock = asyncio.Lock()
|
||||
self._send_lock = asyncio.Lock()
|
||||
self._pending: dict[str, asyncio.Future] = {}
|
||||
@@ -125,6 +131,7 @@ class DeviiSession:
|
||||
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,
|
||||
system_prompt=self._system_prompt,
|
||||
)
|
||||
return await worker.respond(prompt)
|
||||
|
||||
@@ -132,7 +139,8 @@ class DeviiSession:
|
||||
|
||||
def attach(self, ws: Any) -> None:
|
||||
self._conns.add(ws)
|
||||
self._primary = ws
|
||||
self._attach_seq += 1
|
||||
self._conn_meta[ws] = {"seq": self._attach_seq, "visible": True, "focused": False}
|
||||
self._connected.set()
|
||||
self._disconnected.clear()
|
||||
self.avatar.bind(self._avatar_request)
|
||||
@@ -148,8 +156,7 @@ class DeviiSession:
|
||||
|
||||
def detach(self, ws: Any) -> None:
|
||||
self._conns.discard(ws)
|
||||
if self._primary is ws:
|
||||
self._primary = next(iter(self._conns), None)
|
||||
self._conn_meta.pop(ws, None)
|
||||
if not self._conns:
|
||||
self._connected.clear()
|
||||
self._disconnected.set()
|
||||
@@ -157,6 +164,25 @@ class DeviiSession:
|
||||
self.browser.unbind()
|
||||
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)
|
||||
if meta is not None:
|
||||
meta["visible"] = visible
|
||||
meta["focused"] = focused
|
||||
|
||||
def _pick_target(self) -> Any:
|
||||
best = None
|
||||
best_rank = None
|
||||
for ws in self._conns:
|
||||
meta = self._conn_meta.get(ws)
|
||||
if meta is None:
|
||||
continue
|
||||
rank = (meta["focused"], meta["visible"], meta["seq"])
|
||||
if best_rank is None or rank > best_rank:
|
||||
best_rank = rank
|
||||
best = ws
|
||||
return best
|
||||
|
||||
@property
|
||||
def connection_count(self) -> int:
|
||||
return len(self._conns)
|
||||
@@ -185,7 +211,7 @@ class DeviiSession:
|
||||
if messages and messages[0].get("role") == "system":
|
||||
system = messages[0]
|
||||
else:
|
||||
system = {"role": "system", "content": SYSTEM_PROMPT}
|
||||
system = {"role": "system", "content": self._system_prompt}
|
||||
self.agent._messages = [system]
|
||||
self._buffer = []
|
||||
if self.persist_conversation:
|
||||
@@ -216,6 +242,17 @@ class DeviiSession:
|
||||
finally:
|
||||
self._record_turn(turn_id, started_at, text, reply, error, before)
|
||||
|
||||
def _quota_snapshot(self) -> dict[str, Any]:
|
||||
spent = self._ledger.spent_24h(self.owner_kind, self.owner_id)
|
||||
turns = self._ledger.turns_24h(self.owner_kind, self.owner_id)
|
||||
limit = self.settings.daily_limit_usd
|
||||
used_pct = round(min(100.0, spent / limit * 100), 1) if limit > 0 else 0.0
|
||||
return {
|
||||
"used_pct": used_pct,
|
||||
"turns_today": turns,
|
||||
"limit_reached": limit > 0 and spent >= limit,
|
||||
}
|
||||
|
||||
def _cost_snapshot(self) -> dict[str, Any]:
|
||||
return {
|
||||
"requests": self.cost.requests,
|
||||
@@ -332,7 +369,7 @@ class DeviiSession:
|
||||
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
|
||||
ws = self._primary
|
||||
ws = self._pick_target()
|
||||
if ws is None:
|
||||
continue
|
||||
try:
|
||||
@@ -361,6 +398,20 @@ class DeviiSession:
|
||||
asyncio.create_task(self._emit(message, buffer=True))
|
||||
|
||||
|
||||
NON_ADMIN_COST_RULE = (
|
||||
"\n\nThe current user is NOT an administrator. Never reveal any monetary cost, dollar "
|
||||
"amount, pricing, or spend figure to them under any circumstance. When asked about "
|
||||
"usage or resources, call usage_quota and report only the percentage of their 24h "
|
||||
"quota used and the turn count."
|
||||
)
|
||||
|
||||
|
||||
def _system_prompt_for(is_admin: bool) -> str:
|
||||
if is_admin:
|
||||
return SYSTEM_PROMPT
|
||||
return SYSTEM_PROMPT + NON_ADMIN_COST_RULE
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
from datetime import datetime, timezone
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
@@ -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