Version 1.2.2. Added usage overview. Shows token used and cost in $.

This commit is contained in:
2026-04-15 10:00:39 +02:00
parent 752691fe54
commit d4c6420481
18 changed files with 1657 additions and 86 deletions

View File

@@ -348,7 +348,7 @@ Contributions are welcome!
oAI-Web takes real actions on your behalf — it can send emails, write files, make calendar changes, and post Telegram messages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind — the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms.
--
---
**⭐ Star this project if you find it useful!**

574
sbom.cdx.json Normal file
View File

@@ -0,0 +1,574 @@
{
"components": [
{
"bom-ref": "requirements-L24",
"description": "requirements line 24: aioimaplib>=1.0",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/aioimaplib/"
}
],
"name": "aioimaplib",
"purl": "pkg:pypi/aioimaplib",
"type": "library"
},
{
"bom-ref": "requirements-L9",
"description": "requirements line 9: anthropic==0.40.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/anthropic/"
}
],
"name": "anthropic",
"purl": "pkg:pypi/anthropic@0.40.%2A",
"type": "library",
"version": "0.40.*"
},
{
"bom-ref": "requirements-L33",
"description": "requirements line 33: apscheduler==3.10.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/apscheduler/"
}
],
"name": "apscheduler",
"purl": "pkg:pypi/apscheduler@3.10.%2A",
"type": "library",
"version": "3.10.*"
},
{
"bom-ref": "requirements-L36",
"description": "requirements line 36: argon2-cffi==23.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/argon2-cffi/"
}
],
"name": "argon2-cffi",
"purl": "pkg:pypi/argon2-cffi@23.%2A",
"type": "library",
"version": "23.*"
},
{
"bom-ref": "requirements-L41",
"description": "requirements line 41: asyncpg==0.31.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/asyncpg/"
}
],
"name": "asyncpg",
"purl": "pkg:pypi/asyncpg@0.31.%2A",
"type": "library",
"version": "0.31.*"
},
{
"bom-ref": "requirements-L28",
"description": "requirements line 28: beautifulsoup4==4.12.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/beautifulsoup4/"
}
],
"name": "beautifulsoup4",
"purl": "pkg:pypi/beautifulsoup4@4.12.%2A",
"type": "library",
"version": "4.12.*"
},
{
"bom-ref": "requirements-L19",
"description": "requirements line 19: caldav==1.3.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/caldav/"
}
],
"name": "caldav",
"purl": "pkg:pypi/caldav@1.3.%2A",
"type": "library",
"version": "1.3.*"
},
{
"bom-ref": "requirements-L13",
"description": "requirements line 13: cryptography==43.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/cryptography/"
}
],
"name": "cryptography",
"purl": "pkg:pypi/cryptography@43.%2A",
"type": "library",
"version": "43.*"
},
{
"bom-ref": "requirements-L2",
"description": "requirements line 2: fastapi==0.115.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/fastapi/"
}
],
"name": "fastapi",
"purl": "pkg:pypi/fastapi@0.115.%2A",
"type": "library",
"version": "0.115.*"
},
{
"bom-ref": "requirements-L29",
"description": "requirements line 29: feedparser==6.0.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/feedparser/"
}
],
"name": "feedparser",
"purl": "pkg:pypi/feedparser@6.0.%2A",
"type": "library",
"version": "6.0.*"
},
{
"bom-ref": "requirements-L27",
"description": "requirements line 27: httpx==0.27.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/httpx/"
}
],
"name": "httpx",
"purl": "pkg:pypi/httpx@0.27.%2A",
"type": "library",
"version": "0.27.*"
},
{
"bom-ref": "requirements-L23",
"description": "requirements line 23: imapclient==3.0.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/imapclient/"
}
],
"name": "imapclient",
"purl": "pkg:pypi/imapclient@3.0.%2A",
"type": "library",
"version": "3.0.*"
},
{
"bom-ref": "requirements-L4",
"description": "requirements line 4: jinja2==3.1.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/jinja2/"
}
],
"name": "jinja2",
"purl": "pkg:pypi/jinja2@3.1.%2A",
"type": "library",
"version": "3.1.*"
},
{
"bom-ref": "requirements-L42",
"description": "requirements line 42: mcp==1.26.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/mcp/"
}
],
"name": "mcp",
"purl": "pkg:pypi/mcp@1.26.%2A",
"type": "library",
"version": "1.26.*"
},
{
"bom-ref": "requirements-L10",
"description": "requirements line 10: openai==1.57.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/openai/"
}
],
"name": "openai",
"purl": "pkg:pypi/openai@1.57.%2A",
"type": "library",
"version": "1.57.*"
},
{
"bom-ref": "requirements-L30",
"description": "requirements line 30: playwright>=1.40",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/playwright/"
}
],
"name": "playwright",
"purl": "pkg:pypi/playwright",
"type": "library"
},
{
"bom-ref": "requirements-L37",
"description": "requirements line 37: pyotp>=2.9",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/pyotp/"
}
],
"name": "pyotp",
"purl": "pkg:pypi/pyotp",
"type": "library"
},
{
"bom-ref": "requirements-L45",
"description": "requirements line 45: python-dateutil==2.9.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/python-dateutil/"
}
],
"name": "python-dateutil",
"purl": "pkg:pypi/python-dateutil@2.9.%2A",
"type": "library",
"version": "2.9.*"
},
{
"bom-ref": "requirements-L16",
"description": "requirements line 16: python-dotenv==1.0.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/python-dotenv/"
}
],
"name": "python-dotenv",
"purl": "pkg:pypi/python-dotenv@1.0.%2A",
"type": "library",
"version": "1.0.*"
},
{
"bom-ref": "requirements-L5",
"description": "requirements line 5: python-multipart==0.0.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/python-multipart/"
}
],
"name": "python-multipart",
"purl": "pkg:pypi/python-multipart@0.0.%2A",
"type": "library",
"version": "0.0.*"
},
{
"bom-ref": "requirements-L46",
"description": "requirements line 46: pytz==2024.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/pytz/"
}
],
"name": "pytz",
"purl": "pkg:pypi/pytz@2024.%2A",
"type": "library",
"version": "2024.*"
},
{
"bom-ref": "requirements-L38",
"description": "requirements line 38: qrcode[pil]>=7.4",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/qrcode/"
}
],
"name": "qrcode",
"properties": [
{
"name": "cdx:python:package:required-extra",
"value": "pil"
}
],
"purl": "pkg:pypi/qrcode",
"type": "library"
},
{
"bom-ref": "requirements-L3",
"description": "requirements line 3: uvicorn[standard]==0.32.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/uvicorn/"
}
],
"name": "uvicorn",
"properties": [
{
"name": "cdx:python:package:required-extra",
"value": "standard"
}
],
"purl": "pkg:pypi/uvicorn@0.32.%2A",
"type": "library",
"version": "0.32.*"
},
{
"bom-ref": "requirements-L20",
"description": "requirements line 20: vobject==0.9.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/vobject/"
}
],
"name": "vobject",
"purl": "pkg:pypi/vobject@0.9.%2A",
"type": "library",
"version": "0.9.*"
},
{
"bom-ref": "requirements-L6",
"description": "requirements line 6: websockets==13.*",
"externalReferences": [
{
"comment": "implicit dist url",
"type": "distribution",
"url": "https://pypi.org/simple/websockets/"
}
],
"name": "websockets",
"purl": "pkg:pypi/websockets@13.%2A",
"type": "library",
"version": "13.*"
}
],
"dependencies": [
{
"ref": "requirements-L10"
},
{
"ref": "requirements-L13"
},
{
"ref": "requirements-L16"
},
{
"ref": "requirements-L19"
},
{
"ref": "requirements-L2"
},
{
"ref": "requirements-L20"
},
{
"ref": "requirements-L23"
},
{
"ref": "requirements-L24"
},
{
"ref": "requirements-L27"
},
{
"ref": "requirements-L28"
},
{
"ref": "requirements-L29"
},
{
"ref": "requirements-L3"
},
{
"ref": "requirements-L30"
},
{
"ref": "requirements-L33"
},
{
"ref": "requirements-L36"
},
{
"ref": "requirements-L37"
},
{
"ref": "requirements-L38"
},
{
"ref": "requirements-L4"
},
{
"ref": "requirements-L41"
},
{
"ref": "requirements-L42"
},
{
"ref": "requirements-L45"
},
{
"ref": "requirements-L46"
},
{
"ref": "requirements-L5"
},
{
"ref": "requirements-L6"
},
{
"ref": "requirements-L9"
}
],
"metadata": {
"timestamp": "2026-04-15T07:29:58.838584+00:00",
"tools": {
"components": [
{
"description": "CycloneDX Software Bill of Materials (SBOM) generator for Python projects and environments",
"externalReferences": [
{
"type": "build-system",
"url": "https://github.com/CycloneDX/cyclonedx-python/actions"
},
{
"type": "distribution",
"url": "https://pypi.org/project/cyclonedx-bom/"
},
{
"type": "documentation",
"url": "https://cyclonedx-bom-tool.readthedocs.io/"
},
{
"type": "issue-tracker",
"url": "https://github.com/CycloneDX/cyclonedx-python/issues"
},
{
"type": "license",
"url": "https://github.com/CycloneDX/cyclonedx-python/blob/main/LICENSE"
},
{
"type": "release-notes",
"url": "https://github.com/CycloneDX/cyclonedx-python/blob/main/CHANGELOG.md"
},
{
"type": "vcs",
"url": "https://github.com/CycloneDX/cyclonedx-python/"
},
{
"type": "website",
"url": "https://github.com/CycloneDX/cyclonedx-python/#readme"
}
],
"group": "CycloneDX",
"licenses": [
{
"license": {
"acknowledgement": "declared",
"id": "Apache-2.0"
}
}
],
"name": "cyclonedx-py",
"type": "application",
"version": "7.3.0"
},
{
"description": "Python library for CycloneDX",
"externalReferences": [
{
"type": "build-system",
"url": "https://github.com/CycloneDX/cyclonedx-python-lib/actions"
},
{
"type": "distribution",
"url": "https://pypi.org/project/cyclonedx-python-lib/"
},
{
"type": "documentation",
"url": "https://cyclonedx-python-library.readthedocs.io/"
},
{
"type": "issue-tracker",
"url": "https://github.com/CycloneDX/cyclonedx-python-lib/issues"
},
{
"type": "license",
"url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE"
},
{
"type": "release-notes",
"url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md"
},
{
"type": "vcs",
"url": "https://github.com/CycloneDX/cyclonedx-python-lib"
},
{
"type": "website",
"url": "https://github.com/CycloneDX/cyclonedx-python-lib/#readme"
}
],
"group": "CycloneDX",
"licenses": [
{
"license": {
"acknowledgement": "declared",
"id": "Apache-2.0"
}
}
],
"name": "cyclonedx-python-lib",
"type": "library",
"version": "11.7.0"
}
]
}
},
"serialNumber": "urn:uuid:2d68f514-7d51-45bc-957f-4df5affd9778",
"version": 1,
"$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.6"
}

View File

@@ -470,7 +470,7 @@ class Agent:
confirmed = False
# Confirmation flow (interactive sessions only)
if tool.requires_confirmation and task_id is None:
if await tool.should_confirm(**tc.arguments) and task_id is None:
description = tool.confirmation_description(**tc.arguments)
yield ConfirmationRequiredEvent(
call_id=tc.id,
@@ -697,12 +697,18 @@ class Agent:
# Update in-memory history for multi-turn
self._session_history[session_id] = messages
# Persist conversation to DB
# Persist conversation to DB (with accumulated usage for cost tracking)
from ..providers.models import compute_cost_usd as _compute_cost
_model_str = model or "" # full "provider:model" string for pricing lookup
_cost = _compute_cost(_model_str, total_usage.input_tokens, total_usage.output_tokens) if _model_str else None
await _save_conversation(
session_id=session_id,
messages=messages,
task_id=task_id,
model=response.model or run_model or model or "",
input_tokens=total_usage.input_tokens,
output_tokens=total_usage.output_tokens,
cost_usd=_cost,
)
yield DoneEvent(
@@ -735,6 +741,9 @@ async def _save_conversation(
messages: list[dict],
task_id: str | None,
model: str = "",
input_tokens: int = 0,
output_tokens: int = 0,
cost_usd: float | None = None,
) -> None:
from ..context_vars import current_user as _cu
user_id = _cu.get().id if _cu.get() else None
@@ -745,26 +754,41 @@ async def _save_conversation(
"SELECT id, title FROM conversations WHERE id = $1", session_id
)
if existing:
# Only update title if still unset (don't overwrite a user-renamed title)
# Accumulate tokens across turns; only update title if still unset
if not existing["title"]:
title = _derive_title(messages)
await pool.execute(
"UPDATE conversations SET messages = $1, ended_at = $2, title = $3, model = $4 WHERE id = $5",
messages, now, title, model or None, session_id,
"""UPDATE conversations
SET messages = $1, ended_at = $2, title = $3, model = $4,
input_tokens = COALESCE(input_tokens, 0) + $5,
output_tokens = COALESCE(output_tokens, 0) + $6,
cost_usd = COALESCE(cost_usd, 0) + COALESCE($7, 0)
WHERE id = $8""",
messages, now, title, model or None,
input_tokens, output_tokens, cost_usd, session_id,
)
else:
await pool.execute(
"UPDATE conversations SET messages = $1, ended_at = $2, model = $3 WHERE id = $4",
messages, now, model or None, session_id,
"""UPDATE conversations
SET messages = $1, ended_at = $2, model = $3,
input_tokens = COALESCE(input_tokens, 0) + $4,
output_tokens = COALESCE(output_tokens, 0) + $5,
cost_usd = COALESCE(cost_usd, 0) + COALESCE($6, 0)
WHERE id = $7""",
messages, now, model or None,
input_tokens, output_tokens, cost_usd, session_id,
)
else:
title = _derive_title(messages)
await pool.execute(
"""
INSERT INTO conversations (id, started_at, ended_at, messages, task_id, user_id, title, model)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
INSERT INTO conversations
(id, started_at, ended_at, messages, task_id, user_id, title, model,
input_tokens, output_tokens, cost_usd)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
""",
session_id, now, now, messages, task_id, user_id, title, model or None,
input_tokens, output_tokens, cost_usd,
)
except Exception as e:
logger.error(f"Failed to save conversation {session_id}: {e}")

View File

@@ -277,12 +277,17 @@ class AgentRunner:
elif isinstance(event, ErrorEvent):
final_text = f"Error: {event.message}"
run_model = agent_data.get("model") or ""
from ..providers.models import compute_cost_usd
cost = compute_cost_usd(run_model, input_tokens, output_tokens) if run_model else None
await agent_store.finish_run(
run_id,
status="success",
input_tokens=input_tokens,
output_tokens=output_tokens,
result=final_text,
model=run_model or None,
cost_usd=cost,
)
logger.info(
f"[agent-runner] Agent '{agent_data['name']}' run={run_id[:8]} completed OK"

View File

@@ -1,5 +1,20 @@
"""
agents/tasks.py — Agent and agent run CRUD operations (async).
agents/tasks.py — Agent and agent run CRUD operations (async, PostgreSQL).
Agents are persistent, named, goal-oriented configurations that can be:
- Run manually (via the Agents UI or /api/agents/{id}/run)
- Scheduled with a cron expression (managed by AgentRunner + APScheduler)
- Triggered by email, Telegram, webhooks, or monitors
agent_runs records every execution — input/output tokens, status, result text.
This is the source of truth for the Agents UI run history and token totals.
Design note on allowed_tools:
- NULL in DB means "all tools" (unlimited access)
- [] (empty list) is falsy in Python — treated identically to NULL
- Non-empty list restricts the agent to exactly those tools
- This is enforced structurally: only declared schemas are sent to the model;
it is impossible for the model to call a tool it wasn't given a schema for.
"""
from __future__ import annotations
@@ -161,6 +176,8 @@ async def finish_run(
output_tokens: int = 0,
result: str | None = None,
error: str | None = None,
model: str | None = None,
cost_usd: float | None = None,
) -> dict | None:
now = _now()
pool = await get_pool()
@@ -168,10 +185,12 @@ async def finish_run(
"""
UPDATE agent_runs
SET ended_at = $1, status = $2, input_tokens = $3,
output_tokens = $4, result = $5, error = $6
WHERE id = $7
output_tokens = $4, result = $5, error = $6,
model = $7, cost_usd = $8
WHERE id = $9
""",
now, status, input_tokens, output_tokens, result, error, run_id,
now, status, input_tokens, output_tokens, result, error,
model or None, cost_usd, run_id,
)
return await get_run(run_id)

View File

@@ -465,6 +465,29 @@ _MIGRATIONS: list[list[str]] = [
[
"ALTER TABLE webhook_targets ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id) ON DELETE CASCADE",
],
# v28 — Browser trusted domains (per-user interaction pre-approval)
[
"""
CREATE TABLE IF NOT EXISTS browser_approved_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
domain TEXT NOT NULL,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (owner_user_id, domain)
)
""",
],
# v29 — Track model per agent run for usage/cost overview
[
"ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS model TEXT",
],
# v30 — Track token usage and cost per chat conversation
[
"ALTER TABLE conversations ADD COLUMN IF NOT EXISTS input_tokens INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE conversations ADD COLUMN IF NOT EXISTS output_tokens INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE conversations ADD COLUMN IF NOT EXISTS cost_usd REAL",
],
]
@@ -821,7 +844,15 @@ filesystem_whitelist_store = FilesystemWhitelistStore()
# ─── Initialisation ───────────────────────────────────────────────────────────
async def _init_connection(conn: asyncpg.Connection) -> None:
"""Register codecs on every new connection so asyncpg handles JSONB ↔ dict."""
"""
Register codecs on every new connection so asyncpg handles JSONB ↔ dict automatically.
Critical: asyncpg does NOT auto-serialize Python dicts to PostgreSQL JSONB.
Without this, inserting a dict into a JSONB column raises:
asyncpg.exceptions.UnsupportedClientFeatureError: ...
The codecs must be registered on every connection (not just once on the pool),
which is why this is passed as `init=` to create_pool().
"""
await conn.set_type_codec(
"jsonb",
encoder=json.dumps,

View File

@@ -85,6 +85,14 @@ class EmailAccountListener:
# ── Main loop ─────────────────────────────────────────────────────────────
async def _run_loop(self) -> None:
"""
Outer retry loop. Restarts the inner trigger/handling loop on any error
with exponential backoff (5s → 10s → 20s → ... → 60s max).
CancelledError is propagated cleanly (listener.stop() cancels the task).
Any other exception is logged and retried — typical causes: IMAP disconnect,
network timeout, credential rotation. Backoff resets on successful cycle.
"""
backoff = 5
while True:
try:

View File

@@ -2,9 +2,38 @@
main.py — FastAPI application entry point.
Provides:
- HTML pages: /, /agents, /audit, /settings, /login, /setup, /admin/users
- WebSocket: /ws/{session_id} (streaming agent responses)
- REST API: /api/*
- HTML pages: /, /chats, /agents, /models, /audit, /monitors, /files,
/settings, /login, /setup, /admin/users, /help
- WebSocket: /ws/{session_id} (streaming agent responses)
- REST API: /api/* (see server/web/routes.py)
- Brain MCP: /brain-mcp/sse (MCP protocol for 2nd Brain access)
Startup order (lifespan):
1. init_db() — run PostgreSQL migrations, create pool
2. _refresh_brand_globals() — load brand name/logo from DB into Jinja2 globals
3. _ensure_session_secret() — auto-generate HMAC signing secret if not set
4. check user_count() — set _needs_setup flag (redirects to /setup if 0 users)
5. cleanup_stale_runs() — mark any interrupted "running" agent_runs as "error"
6. init_brain_db() — connect to brain PostgreSQL (pgvector)
7. build_registry() — create ToolRegistry with all production tools
8. discover_and_register_mcp_tools() — connect to configured MCP servers and add their tools
9. Agent(registry=...) — create the singleton agent loop
10. agent_runner.init/start() — load agent cron schedules into APScheduler, start scheduler
11. page_monitor / rss_monitor — wire into shared APScheduler, load schedules
12. _migrate_email_accounts() — one-time migration of old inbox:* credentials
13. inbox_listener.start_all() — start IMAP listeners for all email accounts
14. telegram_listener.start() — start Telegram long-polling listener
15. _session_manager.run() — start Brain MCP session manager
Key singletons (module-level, shared across all requests):
_agent: Agent instance — owns in-memory session history
_registry: ToolRegistry — the set of available tools
Both are initialised in lifespan() and referenced by the WebSocket handler.
Auth middleware (_AuthMiddleware):
Runs on every request before route handlers. Validates the session cookie or
API key, stores the resolved CurrentUser in the current_user ContextVar.
Routes use _require_auth() / _require_admin() helpers to enforce access control.
"""
from __future__ import annotations
@@ -716,6 +745,22 @@ async def setup_post(request: Request):
# ── HTML pages ────────────────────────────────────────────────────────────────
async def _user_can_view_usage(user) -> bool:
"""Admins always; non-admins only if they have their own API key and aren't on admin keys."""
if not user:
return False
if user.is_admin:
return True
from .database import user_settings_store
if await user_settings_store.get(user.id, "use_admin_keys"):
return False
return bool(
await user_settings_store.get(user.id, "anthropic_api_key") or
await user_settings_store.get(user.id, "openrouter_api_key") or
await user_settings_store.get(user.id, "openai_api_key")
)
async def _ctx(request: Request, **extra):
"""Build template context with current_user and active theme CSS injected."""
from .web.themes import get_theme_css, DEFAULT_THEME
@@ -734,6 +779,7 @@ async def _ctx(request: Request, **extra):
"current_user": user,
"theme_css": theme_css,
"needs_personality_setup": needs_personality_setup,
"can_view_usage": await _user_can_view_usage(user),
**extra,
}
@@ -765,6 +811,15 @@ async def models_page(request: Request):
return templates.TemplateResponse("models.html", await _ctx(request))
@app.get("/usage", response_class=HTMLResponse)
async def usage_page(request: Request):
user = _get_current_user(request)
if not await _user_can_view_usage(user):
from fastapi.responses import RedirectResponse
return RedirectResponse("/", status_code=303)
return templates.TemplateResponse("usage.html", await _ctx(request))
@app.get("/audit", response_class=HTMLResponse)
async def audit_page(request: Request):
return templates.TemplateResponse("audit.html", await _ctx(request))

View File

@@ -372,6 +372,50 @@ async def get_models_info(
return results
def get_model_pricing(model_id: str) -> tuple[float | None, float | None]:
"""
Return (prompt_per_1m, completion_per_1m) in USD for the given model ID.
Uses only in-memory data (hardcoded Anthropic/OpenAI + cached OpenRouter raw).
Returns (None, None) if pricing is unknown for this model.
model_id format: "anthropic:claude-sonnet-4-6", "openrouter:openai/gpt-4o", "openai:gpt-4o"
"""
for m in _ANTHROPIC_MODEL_INFO:
if m["id"] == model_id:
p = m["pricing"]
return p["prompt_per_1m"], p["completion_per_1m"]
for m in _OPENAI_MODEL_INFO:
if m["id"] == model_id:
p = m["pricing"]
return p["prompt_per_1m"], p["completion_per_1m"]
# OpenRouter: strip "openrouter:" prefix to get the bare OR model id
if model_id.startswith("openrouter:"):
bare = model_id[len("openrouter:"):]
for m in _or_raw:
if m.get("id", "") == bare and not _is_free_openrouter(m):
pricing = m.get("pricing", {})
try:
prompt = float(pricing.get("prompt", 0)) * 1_000_000
completion = float(pricing.get("completion", 0)) * 1_000_000
return prompt, completion
except (TypeError, ValueError):
return None, None
return None, None
def compute_cost_usd(
model_id: str,
input_tokens: int,
output_tokens: int,
) -> float | None:
"""Compute estimated cost in USD for a completed run. Returns None if pricing unknown."""
prompt_per_1m, completion_per_1m = get_model_pricing(model_id)
if prompt_per_1m is None or completion_per_1m is None:
return None
return (input_tokens / 1_000_000) * prompt_per_1m + (output_tokens / 1_000_000) * completion_per_1m
async def get_access_tier(
user_id: str | None = None,
is_admin: bool = True,

View File

@@ -62,6 +62,14 @@ class BaseTool(ABC):
"input_schema": self.input_schema,
}
async def should_confirm(self, **kwargs) -> bool:
"""
Return True if this specific call requires confirmation.
Override in subclasses for per-call logic (e.g. domain allow-lists).
Default: return self.requires_confirmation.
"""
return self.requires_confirmation
def confirmation_description(self, **kwargs) -> str:
"""
Human-readable description of the action shown to the user

View File

@@ -1,9 +1,12 @@
"""
tools/browser_tool.py — Playwright headless browser tool.
For JS-heavy pages that httpx can't render. Enforces the same Tier 1/2
web whitelist as WebTool. Browser instance is lazy-initialized and shared
across calls.
Read operations (fetch_page, screenshot) never require confirmation.
Interactive operations (click, fill, select, press) require confirmation
unless the target domain is in the user's browser_approved_domains list.
Sessions are stateful within a session_id: navigate with fetch_page first,
then use interactive ops without a url to act on the current page.
Requires: playwright package + `playwright install chromium`
"""
@@ -13,7 +16,7 @@ import asyncio
import logging
from typing import ClassVar
from ..context_vars import current_task_id, web_tier2_enabled
from ..context_vars import current_task_id, current_session_id, web_tier2_enabled
from ..security import assert_domain_tier1, sanitize_external_content
from .base import BaseTool, ToolResult
@@ -21,14 +24,39 @@ logger = logging.getLogger(__name__)
_MAX_TEXT_CHARS = 25_000
_TIMEOUT_MS = 30_000
_USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
_INTERACTIVE_OPS = {"click", "fill", "select", "press"}
async def _is_domain_approved(user_id: str, hostname: str) -> bool:
"""Return True if hostname (or a parent domain) is in the user's approved list."""
from ..database import get_pool
pool = await get_pool()
rows = await pool.fetch(
"SELECT domain FROM browser_approved_domains WHERE owner_user_id = $1",
user_id,
)
hostname = hostname.lower()
for row in rows:
d = row["domain"].lower().lstrip("*.")
if hostname == d or hostname.endswith("." + d):
return True
return False
class BrowserTool(BaseTool):
name = "browser"
description = (
"Fetch web pages using a real headless browser (Chromium). "
"Use this for JS-heavy pages or single-page apps that the regular 'web' tool cannot read. "
"Operations: fetch_page (extract text content), screenshot (base64 PNG). "
"Headless Chromium browser for JS-heavy pages and web interactions. "
"Read ops: fetch_page (extract text), screenshot (PNG). "
"Interactive ops: click, fill (type into field), select (dropdown), press (keyboard key). "
"Interactive ops require confirmation unless the domain is in your Browser Trusted Domains list. "
"Page state is kept across calls within the same session — navigate with fetch_page first, "
"then use interactive ops (omit url to stay on the current page). "
"Follows the same domain whitelist rules as the web tool."
)
input_schema = {
@@ -36,87 +64,129 @@ class BrowserTool(BaseTool):
"properties": {
"operation": {
"type": "string",
"enum": ["fetch_page", "screenshot"],
"description": "fetch_page extracts text; screenshot returns a base64 PNG.",
"enum": ["fetch_page", "screenshot", "click", "fill", "select", "press"],
"description": (
"fetch_page: extract page text. "
"screenshot: capture PNG. "
"click: click an element (selector required). "
"fill: type into a field (selector + value required). "
"select: choose a <select> option (selector + value required). "
"press: press a keyboard key (key required; selector optional)."
),
},
"url": {
"type": "string",
"description": "URL to navigate to.",
"description": (
"URL to navigate to. Required for fetch_page and screenshot. "
"For interactive ops, omit to act on the current page."
),
},
"selector": {
"type": "string",
"description": "CSS selector for click / fill / select / press operations.",
},
"value": {
"type": "string",
"description": "Text to type (fill) or option value to select (select).",
},
"key": {
"type": "string",
"description": "Key name for press (e.g. 'Enter', 'Tab', 'Escape', 'ArrowDown').",
},
"wait_for": {
"type": "string",
"description": "CSS selector to wait for before extracting content (optional).",
"description": "CSS selector to wait for before acting (optional).",
},
"extract_selector": {
"type": "string",
"description": "CSS selector to extract text from (optional; defaults to full page).",
"description": "CSS selector for text extraction in fetch_page (optional; defaults to full page body).",
},
},
"required": ["operation", "url"],
"required": ["operation"],
}
requires_confirmation = False
allowed_in_scheduled_tasks = False # Too resource-heavy for scheduled agents
requires_confirmation = True # default; read ops override via should_confirm()
allowed_in_scheduled_tasks = False
# Module-level shared browser/playwright (lazy-init, reused)
# Shared Playwright/browser instance (lazy-init)
_playwright = None
_browser = None
_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
async def execute(self, operation: str, url: str = "", wait_for: str = "", extract_selector: str = "", **_) -> ToolResult:
if not url:
return ToolResult(success=False, error="'url' is required")
# Per-session pages: session_id → (context, page)
_sessions: ClassVar[dict] = {}
# Whitelist check (same Tier 1/2 rules as WebTool)
denied = await self._check_tier(url)
if denied:
return denied
# ── Confirmation logic ────────────────────────────────────────────────────
try:
from playwright.async_api import async_playwright
except ImportError:
return ToolResult(
success=False,
error="Playwright is not installed. Run: pip install playwright && playwright install chromium",
)
async def should_confirm(self, operation: str = "", url: str = "", **_) -> bool:
if operation not in _INTERACTIVE_OPS:
return False # read-only ops never need confirmation
try:
# Determine the target hostname
target_url = url
if not target_url:
# Acting on the current page — check its URL
sid = current_session_id.get() or "default"
data = BrowserTool._sessions.get(sid)
if data:
try:
target_url = data[1].url
except Exception:
pass
if not target_url:
return True # Unknown target → confirm to be safe
from urllib.parse import urlparse
hostname = urlparse(target_url).hostname or ""
if not hostname:
return True
from ..context_vars import current_user as _cu
user = _cu.get()
if not user:
return True
return not await _is_domain_approved(user.id, hostname)
def confirmation_description(self, operation: str = "", url: str = "",
selector: str = "", value: str = "", key: str = "", **_) -> str:
loc = url or "current page"
if operation == "click":
return f"Click '{selector}' on {loc}"
if operation == "fill":
display_val = value[:40] + "" if len(value) > 40 else value
return f"Type \"{display_val}\" into '{selector}' on {loc}"
if operation == "select":
return f"Select '{value}' in '{selector}' on {loc}"
if operation == "press":
return f"Press '{key}' on {loc}"
return super().confirmation_description(operation=operation, url=url)
# ── Session management ────────────────────────────────────────────────────
async def _get_page(self, session_id: str, url: str | None = None):
"""Get or create a page for this session; navigate to url if given."""
data = BrowserTool._sessions.get(session_id)
page = None
if data:
context, page = data
if page.is_closed():
try:
await context.close()
except Exception:
pass
page = None
if page is None:
browser = await self._get_browser()
context = await browser.new_context(
user_agent=(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
)
context = await browser.new_context(user_agent=_USER_AGENT)
page = await context.new_page()
try:
await page.goto(url, timeout=_TIMEOUT_MS, wait_until="domcontentloaded")
BrowserTool._sessions[session_id] = (context, page)
if wait_for:
try:
await page.wait_for_selector(wait_for, timeout=10_000)
except Exception:
pass # continue even if selector doesn't appear
if url:
await page.goto(url, timeout=_TIMEOUT_MS, wait_until="domcontentloaded")
if operation == "screenshot":
data = await page.screenshot(type="png")
import base64
return ToolResult(success=True, data={"screenshot_base64": base64.b64encode(data).decode()})
# fetch_page
if extract_selector:
elements = await page.query_selector_all(extract_selector)
text_parts = [await el.inner_text() for el in elements]
text = "\n".join(text_parts)
else:
text = await page.inner_text("body")
text = text[:_MAX_TEXT_CHARS]
text = await sanitize_external_content(text, source="browser")
return ToolResult(success=True, data={"url": url, "text": text, "length": len(text)})
finally:
await context.close()
except Exception as e:
return ToolResult(success=False, error=f"Browser error: {e}")
return page
async def _get_browser(self):
async with BrowserTool._lock:
@@ -129,9 +199,9 @@ class BrowserTool(BaseTool):
logger.info("[browser] Chromium launched")
return BrowserTool._browser
# ── Domain access check ───────────────────────────────────────────────────
async def _check_tier(self, url: str) -> ToolResult | None:
"""Returns ToolResult(success=False) if denied, None if allowed."""
from urllib.parse import urlparse
if await assert_domain_tier1(url):
return None
task_id = current_task_id.get()
@@ -139,11 +209,123 @@ class BrowserTool(BaseTool):
return None
if web_tier2_enabled.get():
return None
from urllib.parse import urlparse
parsed = urlparse(url)
return ToolResult(
success=False,
error=(
f"Domain '{parsed.hostname}' is not in the Tier 1 whitelist. "
"Ask me to fetch a specific external page to enable Tier 2 access."
"Ask me to access a specific external page to enable Tier 2 access."
),
)
# ── Execute ───────────────────────────────────────────────────────────────
async def execute(
self,
operation: str,
url: str = "",
selector: str = "",
value: str = "",
key: str = "",
wait_for: str = "",
extract_selector: str = "",
**_,
) -> ToolResult:
# Read ops require a url
if operation in ("fetch_page", "screenshot") and not url:
return ToolResult(success=False, error=f"'url' is required for {operation}")
try:
from playwright.async_api import async_playwright # noqa: F401
except ImportError:
return ToolResult(
success=False,
error="Playwright is not installed. Run: pip install playwright && playwright install chromium",
)
# Whitelist check
target_url = url
if not target_url:
sid = current_session_id.get() or "default"
data = BrowserTool._sessions.get(sid)
if data:
try:
target_url = data[1].url
except Exception:
pass
if target_url:
denied = await self._check_tier(target_url)
if denied:
return denied
session_id = current_session_id.get() or "default"
try:
page = await self._get_page(session_id, url or None)
if wait_for:
try:
await page.wait_for_selector(wait_for, timeout=10_000)
except Exception:
pass
# ── Read operations ──────────────────────────────────────────────
if operation == "fetch_page":
if extract_selector:
elements = await page.query_selector_all(extract_selector)
text_parts = [await el.inner_text() for el in elements]
text = "\n".join(text_parts)
else:
text = await page.inner_text("body")
text = text[:_MAX_TEXT_CHARS]
text = await sanitize_external_content(text, source="browser")
return ToolResult(success=True, data={"url": page.url, "text": text, "length": len(text)})
if operation == "screenshot":
data = await page.screenshot(type="png")
import base64
return ToolResult(success=True, data={"screenshot_base64": base64.b64encode(data).decode()})
# ── Interactive operations ───────────────────────────────────────
if operation == "click":
if not selector:
return ToolResult(success=False, error="'selector' is required for click")
await page.click(selector, timeout=10_000)
try:
await page.wait_for_load_state("domcontentloaded", timeout=5_000)
except Exception:
pass
preview = (await page.inner_text("body"))[:2000]
return ToolResult(success=True, data={"url": page.url, "page_preview": preview})
if operation == "fill":
if not selector:
return ToolResult(success=False, error="'selector' is required for fill")
await page.fill(selector, value, timeout=10_000)
return ToolResult(success=True, data={"url": page.url, "filled": value})
if operation == "select":
if not selector:
return ToolResult(success=False, error="'selector' is required for select")
await page.select_option(selector, value=value, timeout=10_000)
return ToolResult(success=True, data={"url": page.url, "selected": value})
if operation == "press":
if not key:
return ToolResult(success=False, error="'key' is required for press")
target = selector if selector else "body"
await page.press(target, key, timeout=10_000)
try:
await page.wait_for_load_state("domcontentloaded", timeout=5_000)
except Exception:
pass
preview = (await page.inner_text("body"))[:2000]
return ToolResult(success=True, data={"url": page.url, "page_preview": preview})
return ToolResult(success=False, error=f"Unknown operation: {operation}")
except Exception as e:
return ToolResult(success=False, error=f"Browser error: {e}")

View File

@@ -1,5 +1,21 @@
"""
users.py — User CRUD operations (async, PostgreSQL).
Design decisions:
- User IDs are TEXT (UUID stored as string), NOT the PostgreSQL UUID type.
Reason: PostgreSQL UUID type causes FK mismatch errors with asyncpg when columns
in other tables store user_id as TEXT. Keeping everything TEXT avoids implicit
type casting and makes joins reliable.
- New users get personality files seeded from the global SOUL.md / USER.md.
Admins get the real USER.md verbatim. Non-admins get a blank template so they
can fill in their own context without seeing the admin's personal info.
- User email addresses are automatically added to email_whitelist so the agent
can reply to the user without manual whitelist configuration.
- User folders (provisioned in {users_base_folder}/{username}/) allow non-admin
users to have a private filesystem space without configuring the global whitelist.
"""
from __future__ import annotations
@@ -137,6 +153,12 @@ async def _sync_email_whitelist(pool, old_email: str | None, new_email: str | No
async def create_user(username: str, password: str, role: str = "user", email: str = "") -> dict:
"""
Create a new user. Automatically:
- Seeds per-user personality from global SOUL.md / USER.md
- Adds user's email to email_whitelist (so the agent can reply)
- Provisions a user folder if system:users_base_folder is configured
"""
user_id = str(uuid.uuid4())
now = _now()
pw_hash = hash_password(password)
@@ -222,6 +244,11 @@ async def update_user(user_id: str, **fields) -> bool:
async def delete_user(user_id: str) -> bool:
"""
Delete a user. Nullifies FK references first to avoid constraint violations.
Agents, conversations, and audit entries are preserved (owner set to NULL)
rather than cascade-deleted — important for audit trail integrity.
"""
pool = await get_pool()
# Fetch email before delete for whitelist cleanup
old_row = await pool.fetchrow("SELECT email FROM users WHERE id = $1", user_id)

View File

@@ -380,6 +380,58 @@ async def get_queue_status(request: Request):
return agent_runner.queue_status
# ── Browser trusted domains ───────────────────────────────────────────────────
class BrowserDomainIn(BaseModel):
domain: str
note: Optional[str] = None
@router.get("/my/browser-trusted")
async def list_browser_trusted(request: Request):
from ..database import get_pool as _gp
user = _require_auth(request)
pool = await _gp()
rows = await pool.fetch(
"SELECT id, domain, note, created_at FROM browser_approved_domains "
"WHERE owner_user_id = $1 ORDER BY domain",
user.id,
)
return [dict(r) for r in rows]
@router.post("/my/browser-trusted")
async def add_browser_trusted(request: Request, body: BrowserDomainIn):
from ..database import get_pool as _gp
user = _require_auth(request)
domain = body.domain.lower().strip().lstrip("*.")
if not domain:
raise HTTPException(status_code=400, detail="Invalid domain")
pool = await _gp()
try:
await pool.execute(
"INSERT INTO browser_approved_domains (owner_user_id, domain, note) "
"VALUES ($1, $2, $3) ON CONFLICT (owner_user_id, domain) DO NOTHING",
user.id, domain, body.note or None,
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
return {"ok": True, "domain": domain}
@router.delete("/my/browser-trusted/{domain:path}")
async def remove_browser_trusted(request: Request, domain: str):
from ..database import get_pool as _gp
user = _require_auth(request)
domain = domain.lower().strip().lstrip("*.")
pool = await _gp()
await pool.execute(
"DELETE FROM browser_approved_domains WHERE owner_user_id = $1 AND domain = $2",
user.id, domain,
)
return {"ok": True}
@router.get("/settings/provider")
async def get_default_provider(request: Request):
_require_admin(request)
@@ -814,6 +866,179 @@ async def stop_run(request: Request, run_id: str):
return {"ok": True, "run_id": run_id}
# ── Usage / cost overview ─────────────────────────────────────────────────────
@router.get("/usage")
async def get_usage(
request: Request,
since: str = "7d",
start: str = "",
end: str = "",
):
"""Aggregate token and cost usage by agent for the usage overview page."""
user = _require_auth(request)
if not user.is_admin:
from ..database import user_settings_store as _uss
if await _uss.get(user.id, "use_admin_keys"):
raise HTTPException(status_code=403, detail="Not available on admin API keys")
has_own = (
await _uss.get(user.id, "anthropic_api_key") or
await _uss.get(user.id, "openrouter_api_key") or
await _uss.get(user.id, "openai_api_key")
)
if not has_own:
raise HTTPException(status_code=403, detail="Not available on admin API keys")
from ..database import get_pool as _gp
pool = await _gp()
now = datetime.now(timezone.utc)
since_dt: str | None = None
if start:
since_dt = start
elif since == "today":
since_dt = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
elif since == "7d":
since_dt = (now - timedelta(days=7)).isoformat()
elif since == "30d":
since_dt = (now - timedelta(days=30)).isoformat()
# since == "all" → no date filter
# Build WHERE clauses
clauses: list[str] = ["ar.status IN ('success', 'error', 'stopped')"]
params: list = []
n = 1
# Exclude email handler agents
handler_ids_rows = await pool.fetch(
"SELECT agent_id FROM email_accounts WHERE agent_id IS NOT NULL"
)
handler_ids = [str(r["agent_id"]) for r in handler_ids_rows]
if handler_ids:
placeholders = ", ".join(f"${n + i}" for i in range(len(handler_ids)))
clauses.append(f"ar.agent_id NOT IN ({placeholders})")
params.extend(handler_ids)
n += len(handler_ids)
if since_dt:
clauses.append(f"ar.started_at >= ${n}"); params.append(since_dt); n += 1
if end:
clauses.append(f"ar.started_at <= ${n}"); params.append(end); n += 1
# Non-admin sees only their own agents
if not user.is_admin:
own_agents = await agent_store.list_agents(owner_user_id=user.id)
own_ids = [a["id"] for a in own_agents]
if own_ids:
placeholders = ", ".join(f"${n + i}" for i in range(len(own_ids)))
clauses.append(f"ar.agent_id IN ({placeholders})")
params.extend(own_ids)
n += len(own_ids)
else:
# No agents → empty result
return {"summary": {"runs": 0, "input_tokens": 0, "output_tokens": 0, "cost_usd": None}, "by_agent": []}
where = "WHERE " + " AND ".join(clauses)
rows = await pool.fetch(
f"""
SELECT
ar.agent_id,
a.name AS agent_name,
a.model AS agent_model,
COUNT(*) AS runs,
SUM(ar.input_tokens) AS input_tokens,
SUM(ar.output_tokens) AS output_tokens,
SUM(ar.cost_usd) AS cost_usd
FROM agent_runs ar
LEFT JOIN agents a ON a.id = ar.agent_id
{where}
GROUP BY ar.agent_id, a.name, a.model
ORDER BY cost_usd DESC NULLS LAST, (SUM(ar.input_tokens) + SUM(ar.output_tokens)) DESC
""",
*params,
)
by_agent = []
total_input = 0
total_output = 0
total_cost: float | None = None
total_runs = 0
for row in rows:
inp = int(row["input_tokens"] or 0)
out = int(row["output_tokens"] or 0)
cost = float(row["cost_usd"]) if row["cost_usd"] is not None else None
runs = int(row["runs"])
total_input += inp
total_output += out
total_runs += runs
if cost is not None:
total_cost = (total_cost or 0.0) + cost
by_agent.append({
"agent_id": str(row["agent_id"]),
"agent_name": row["agent_name"] or "",
"model": row["agent_model"] or "",
"runs": runs,
"input_tokens": inp,
"output_tokens": out,
"cost_usd": cost,
})
# ── Chat session usage ────────────────────────────────────────────────────
chat_clauses: list[str] = ["task_id IS NULL"] # chat only, not agent/task runs
chat_params: list = []
cn = 1
if since_dt:
chat_clauses.append(f"started_at >= ${cn}"); chat_params.append(since_dt); cn += 1
if end:
chat_clauses.append(f"started_at <= ${cn}"); chat_params.append(end); cn += 1
if not user.is_admin:
chat_clauses.append(f"user_id = ${cn}"); chat_params.append(user.id); cn += 1
chat_where = "WHERE " + " AND ".join(chat_clauses)
chat_row = await pool.fetchrow(
f"""
SELECT
COUNT(*) AS sessions,
SUM(input_tokens) AS input_tokens,
SUM(output_tokens) AS output_tokens,
SUM(cost_usd) AS cost_usd
FROM conversations
{chat_where}
""",
*chat_params,
)
chat_inp = int(chat_row["input_tokens"] or 0)
chat_out = int(chat_row["output_tokens"] or 0)
chat_cost = float(chat_row["cost_usd"]) if chat_row["cost_usd"] is not None else None
chat_sessions = int(chat_row["sessions"] or 0)
total_input += chat_inp
total_output += chat_out
total_runs += chat_sessions
if chat_cost is not None:
total_cost = (total_cost or 0.0) + chat_cost
return {
"summary": {
"runs": total_runs,
"input_tokens": total_input,
"output_tokens": total_output,
"cost_usd": total_cost,
},
"by_agent": by_agent,
"chat": {
"sessions": chat_sessions,
"input_tokens": chat_inp,
"output_tokens": chat_out,
"cost_usd": chat_cost,
},
}
# ── Inbox triggers ────────────────────────────────────────────────────────────
class InboxTriggerIn(BaseModel):

View File

@@ -215,6 +215,7 @@ function _initPage(url) {
if (path === "/" || path === "") { initChat(); return; }
if (path === "/agents") { initAgents(); return; }
if (path.startsWith("/agents/")) { initAgentDetail(); return; }
if (path === "/usage") { initUsage(); return; }
if (path === "/audit") { initAudit(); return; }
if (path === "/monitors") { initMonitors(); return; }
if (path === "/models") { initModels(); return; }
@@ -1185,6 +1186,122 @@ function respondConfirm(approved) {
document.getElementById("confirm-modal").classList.add("hidden");
}
/* ══════════════════════════════════════════════════════════════════════════
USAGE PAGE
══════════════════════════════════════════════════════════════════════════ */
let _usageRange = "7d";
function setUsageRange(range) {
_usageRange = range;
["today","7d","30d","all"].forEach(r => {
const btn = document.getElementById("usage-range-" + r);
if (!btn) return;
const active = r === range;
btn.style.background = active ? "var(--accent)" : "";
btn.style.color = active ? "#fff" : "";
btn.style.borderColor = active ? "var(--accent)" : "";
});
loadUsage();
}
function _fmtTokens(n) {
if (n === null || n === undefined) return "—";
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
return String(n);
}
function _fmtCost(c) {
if (c === null || c === undefined) return "—";
if (c < 0.001) return "< $0.001";
return "$" + c.toFixed(4);
}
async function loadUsage() {
const tbody = document.getElementById("usage-tbody");
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--text-dim);padding:32px">Loading…</td></tr>';
const resp = await fetch("/api/usage?since=" + encodeURIComponent(_usageRange));
if (!resp.ok) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--danger)">Failed to load usage data</td></tr>';
return;
}
const data = await resp.json();
const s = data.summary;
// Update summary cards
const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
set("u-total-runs", s.runs.toLocaleString());
set("u-input-tokens", _fmtTokens(s.input_tokens));
set("u-output-tokens", _fmtTokens(s.output_tokens));
set("u-total-tokens", _fmtTokens(s.input_tokens + s.output_tokens));
set("u-cost", _fmtCost(s.cost_usd));
// Find max tokens for bar scaling
const agents = data.by_agent;
const maxTokens = agents.reduce((m, a) => Math.max(m, a.input_tokens + a.output_tokens), 1);
let anyNullCost = false;
if (!agents.length) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--text-dim);padding:32px">No runs found for this period</td></tr>';
return;
}
tbody.innerHTML = agents.map(a => {
const total = a.input_tokens + a.output_tokens;
const pct = maxTokens > 0 ? Math.round((total / maxTokens) * 100) : 0;
const costStr = _fmtCost(a.cost_usd);
if (a.cost_usd === null) anyNullCost = true;
const modelShort = a.model ? a.model.replace(/^(anthropic|openrouter|openai):/, "") : "—";
const modelTitle = a.model || "";
return `<tr>
<td><a href="/agents/${a.agent_id}" style="color:var(--accent);text-decoration:none">${esc(a.agent_name)}</a></td>
<td style="font-size:12px;color:var(--text-dim)" title="${esc(modelTitle)}">${esc(modelShort)}</td>
<td style="text-align:right">${a.runs.toLocaleString()}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(a.input_tokens)}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(a.output_tokens)}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(total)}</td>
<td style="text-align:right;font-size:13px;font-weight:${a.cost_usd !== null ? "600" : "400"}">${costStr}</td>
<td>
<div style="background:var(--bg2);border-radius:3px;height:6px;overflow:hidden">
<div style="background:var(--accent);height:100%;width:${pct}%;transition:width 0.3s"></div>
</div>
</td>
</tr>`;
}).join("");
// Chat section
const chatTbody = document.getElementById("usage-chat-tbody");
if (chatTbody) {
const c = data.chat;
if (!c || c.sessions === 0) {
chatTbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-dim);padding:24px">No chat sessions in this period</td></tr>';
} else {
const chatTotal = c.input_tokens + c.output_tokens;
if (c.cost_usd === null) anyNullCost = true;
chatTbody.innerHTML = `<tr>
<td>Interactive chat</td>
<td style="text-align:right">${c.sessions.toLocaleString()}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(c.input_tokens)}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(c.output_tokens)}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(chatTotal)}</td>
<td style="text-align:right;font-size:13px;font-weight:${c.cost_usd !== null ? "600" : "400"}">${_fmtCost(c.cost_usd)}</td>
</tr>`;
}
}
const note = document.getElementById("usage-no-cost-note");
if (note) note.style.display = anyNullCost ? "" : "none";
}
function initUsage() {
if (!document.getElementById("usage-container")) return;
loadUsage();
}
/* ══════════════════════════════════════════════════════════════════════════
AUDIT PAGE
══════════════════════════════════════════════════════════════════════════ */
@@ -1460,6 +1577,7 @@ function switchUserTab(name) {
if (name === "brain") { loadBrainKey("ubrain-mcp-key", "ubrain-mcp-cmd"); loadBrainAutoApprove(); }
if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); }
if (name === "webhooks") { loadMyWebhooks(); loadMyWebhookTargets(); }
if (name === "browser") { loadMyBrowserTrusted(); }
if (name === "pushover") { loadMyPushover(); }
}
@@ -2426,6 +2544,11 @@ function initUserSettings() {
loadMyProviderKeys();
loadMyPersonality();
loadMyMcpServers();
document.getElementById("my-browser-trusted-form")?.addEventListener("submit", async e => {
e.preventDefault();
await addBrowserTrusted("my-bt-domain", "my-bt-note");
});
}
function initSettings() {
@@ -2452,6 +2575,7 @@ function initSettings() {
loadEmailWhitelist();
loadWebWhitelist();
loadFilesystemWhitelist();
loadBrowserTrusted();
reloadCredList();
loadInboxStatus();
loadInboxTriggers();
@@ -2530,6 +2654,11 @@ function initSettings() {
await addFilesystemPath();
});
document.getElementById("browser-trusted-form")?.addEventListener("submit", async e => {
e.preventDefault();
await addBrowserTrusted("bt-domain", "bt-note");
});
document.getElementById("brain-capture-form")?.addEventListener("submit", async e => {
e.preventDefault();
const content = document.getElementById("brain-capture-text").value.trim();
@@ -3029,6 +3158,75 @@ const _DEDICATED_CRED_KEYS = new Set([
// These are managed by Inbox / Telegram / Brain / Security / Branding tabs
]);
/* ── Browser trusted domains ─────────────────────────────────────────────── */
function _renderBrowserTrustedTable(rows, tbodyId) {
const tbody = document.getElementById(tbodyId);
if (!tbody) return;
tbody.innerHTML = "";
if (!rows.length) {
tbody.innerHTML = "<tr><td colspan='3' style='text-align:center;color:var(--text-dim)'>No trusted domains</td></tr>";
return;
}
for (const row of rows) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><code>${esc(row.domain)}</code></td>
<td style="color:var(--text-dim)">${esc(row.note || "")}</td>
<td><button class="btn btn-danger" style="padding:4px 10px;font-size:12px"
onclick="removeBrowserTrusted('${esc(row.domain)}')">Remove</button></td>
`;
tbody.appendChild(tr);
}
}
async function loadBrowserTrusted() {
const r = await fetch("/api/my/browser-trusted");
if (!r.ok) return;
_renderBrowserTrustedTable(await r.json(), "browser-trusted-list");
}
async function loadMyBrowserTrusted() {
const r = await fetch("/api/my/browser-trusted");
if (!r.ok) return;
_renderBrowserTrustedTable(await r.json(), "my-browser-trusted-list");
}
async function addBrowserTrusted(domainInputId, noteInputId) {
const domain = document.getElementById(domainInputId)?.value.trim();
const note = document.getElementById(noteInputId)?.value.trim();
if (!domain) return;
const r = await fetch("/api/my/browser-trusted", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain, note }),
});
if (r.ok) {
document.getElementById(domainInputId).value = "";
document.getElementById(noteInputId).value = "";
showFlash("Added ✓");
loadBrowserTrusted();
loadMyBrowserTrusted();
} else {
const d = await r.json().catch(() => ({}));
alert(d.detail || "Error adding domain");
}
}
async function removeBrowserTrusted(domain) {
if (!confirm(`Remove "${domain}" from trusted domains?`)) return;
const r = await fetch(`/api/my/browser-trusted/${encodeURIComponent(domain)}`, { method: "DELETE" });
if (r.ok) {
showFlash("Removed ✓");
loadBrowserTrusted();
loadMyBrowserTrusted();
} else {
showFlash("Error removing domain");
}
}
/* ── end browser trusted domains ─────────────────────────────────────────── */
async function reloadCredList() {
const r = await fetch("/api/credentials");
if (!r.ok) return;

View File

@@ -609,3 +609,8 @@ tr:hover td { background: var(--bg2); }
.pm-btn:last-child { border-right: none; }
.pm-btn.active { background: var(--accent); color: #fff; }
.pm-btn:hover:not(.active) { background: var(--bg3); color: var(--text); }
/* ── Usage page stat cards ────────────────────────────────────────────────── */
.stat-card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 20px; }
.stat-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-dim); margin-bottom: 6px; }
.stat-value { font-size: 22px; font-weight: 700; color: var(--text); font-family: var(--mono); }

View File

@@ -16,7 +16,7 @@
<img src="{{ logo_url }}" alt="logo" class="sidebar-logo-img">
<div class="sidebar-logo-text">
<div class="sidebar-logo-name">{{ brand_name }}</div>
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.1</span></div>
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.2</span></div>
</div>
</div>
@@ -44,6 +44,12 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M20.188 10.934a8.002 8.002 0 0 1 0 2.132M3.812 13.066a8.002 8.002 0 0 1 0-2.132M15.536 17.121a8 8 0 0 1-1.506.643M9.97 6.236A8 8 0 0 1 11.5 6M17.657 7.757a8 8 0 0 1 .879 1.506M6.343 16.243a8 8 0 0 1-.879-1.506M17.121 15.536a8 8 0 0 1-1.506.879M8.464 6.879A8 8 0 0 1 9.97 6.236"/></svg>
Monitors
</a>
{% if can_view_usage %}
<a class="nav-item" data-page="/usage" href="/usage">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
Usage
</a>
{% endif %}
<a class="nav-item" data-page="/audit" href="/audit">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
Audit Log

View File

@@ -43,6 +43,7 @@
<button type="button" class="tab-btn" id="ustab-brain" onclick="switchUserTab('brain')">2nd Brain</button>
<button type="button" class="tab-btn" id="ustab-pushover" onclick="switchUserTab('pushover')">Pushover</button>
<button type="button" class="tab-btn" id="ustab-webhooks" onclick="switchUserTab('webhooks')">Webhooks</button>
<button type="button" class="tab-btn" id="ustab-browser" onclick="switchUserTab('browser')">Browser</button>
<button type="button" class="tab-btn" id="ustab-mfa" onclick="switchUserTab('mfa')">Profile</button>
</div>
{% endif %}
@@ -417,6 +418,39 @@
</form>
</section>
<div style="border-top:1px solid var(--border);margin-bottom:36px"></div>
<!-- Browser trusted domains -->
<section>
<h2 class="settings-section-title">Browser Trusted Domains</h2>
<p style="font-size:12px;color:var(--text-dim);margin-bottom:12px">
Domains where browser <em>interaction</em> operations (click, fill, select, press) run without
asking for confirmation. Subdomains are automatically included.
Each user manages their own list.
</p>
<div class="table-wrap" style="margin-bottom:16px">
<table>
<thead>
<tr><th>Domain</th><th>Note</th><th>Actions</th></tr>
</thead>
<tbody id="browser-trusted-list">
<tr><td colspan="3" style="text-align:center;color:var(--text-dim)">Loading…</td></tr>
</tbody>
</table>
</div>
<form id="browser-trusted-form" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;max-width:560px">
<div class="form-group" style="flex:1;min-width:160px;margin-bottom:0">
<label>Add domain</label>
<input type="text" id="bt-domain" class="form-input" placeholder="example.com" required autocomplete="off">
</div>
<div class="form-group" style="flex:2;min-width:180px;margin-bottom:0">
<label>Note <span style="color:var(--text-dim)">(optional)</span></label>
<input type="text" id="bt-note" class="form-input" placeholder="e.g. Work intranet">
</div>
<button type="submit" class="btn btn-primary" style="margin-bottom:0">Add</button>
</form>
</section>
</div><!-- /spane-whitelists -->
<!-- ══════════════════════════════════════════════════════════
@@ -1696,6 +1730,37 @@
<!-- ══════════════════════════════════════════════════════════
USER SETTINGS: Profile
═══════════════════════════════════════════════════════════ -->
<div id="uspane-browser" style="display:none">
<section>
<h2 class="settings-section-title">Browser Trusted Domains</h2>
<p style="font-size:12px;color:var(--text-dim);margin-bottom:12px">
Domains where browser <em>interaction</em> operations (click, fill, select, press) run
without asking for confirmation each time. Subdomains are automatically included.
</p>
<div class="table-wrap" style="margin-bottom:16px">
<table>
<thead>
<tr><th>Domain</th><th>Note</th><th>Actions</th></tr>
</thead>
<tbody id="my-browser-trusted-list">
<tr><td colspan="3" style="text-align:center;color:var(--text-dim)">Loading…</td></tr>
</tbody>
</table>
</div>
<form id="my-browser-trusted-form" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;max-width:560px">
<div class="form-group" style="flex:1;min-width:160px;margin-bottom:0">
<label>Add domain</label>
<input type="text" id="my-bt-domain" class="form-input" placeholder="example.com" required autocomplete="off">
</div>
<div class="form-group" style="flex:2;min-width:180px;margin-bottom:0">
<label>Note <span style="color:var(--text-dim)">(optional)</span></label>
<input type="text" id="my-bt-note" class="form-input" placeholder="e.g. Work intranet">
</div>
<button type="submit" class="btn btn-primary" style="margin-bottom:0">Add</button>
</form>
</section>
</div><!-- /uspane-browser -->
<div id="uspane-mfa" style="display:none">
<!-- Theme picker -->

View File

@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}{{ agent_name }} — Usage{% endblock %}
{% block content %}
<div class="page" id="usage-container">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px">
<h1>Usage</h1>
<!-- Time range filter -->
<div style="display:flex;gap:6px">
<button class="btn" id="usage-range-today" type="button" onclick="setUsageRange('today')">Today</button>
<button class="btn" id="usage-range-7d" type="button" onclick="setUsageRange('7d')" style="background:var(--accent);color:#fff;border-color:var(--accent)">7 days</button>
<button class="btn" id="usage-range-30d" type="button" onclick="setUsageRange('30d')">30 days</button>
<button class="btn" id="usage-range-all" type="button" onclick="setUsageRange('all')">All time</button>
</div>
</div>
<!-- Summary cards -->
<div id="usage-cards" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:16px;margin-bottom:32px">
<div class="stat-card">
<div class="stat-label">Runs</div>
<div class="stat-value" id="u-total-runs"></div>
</div>
<div class="stat-card">
<div class="stat-label">Input tokens</div>
<div class="stat-value" id="u-input-tokens"></div>
</div>
<div class="stat-card">
<div class="stat-label">Output tokens</div>
<div class="stat-value" id="u-output-tokens"></div>
</div>
<div class="stat-card">
<div class="stat-label">Total tokens</div>
<div class="stat-value" id="u-total-tokens"></div>
</div>
<div class="stat-card">
<div class="stat-label">Est. cost</div>
<div class="stat-value" id="u-cost"></div>
</div>
</div>
<!-- Per-agent breakdown -->
<h2 style="font-size:14px;color:var(--text-dim);font-weight:500;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:12px">
By agent
</h2>
<div class="table-wrap">
<table id="usage-table">
<thead>
<tr>
<th style="text-align:left">Agent</th>
<th style="text-align:left">Model</th>
<th style="text-align:right">Runs</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Total tokens</th>
<th style="text-align:right">Est. cost</th>
<th style="min-width:120px"></th>
</tr>
</thead>
<tbody id="usage-tbody">
<tr><td colspan="8" style="text-align:center;color:var(--text-dim);padding:32px">Loading…</td></tr>
</tbody>
</table>
</div>
<!-- Chat sessions summary -->
<h2 style="font-size:14px;color:var(--text-dim);font-weight:500;text-transform:uppercase;letter-spacing:0.5px;margin:32px 0 12px">
Chat sessions
</h2>
<div id="usage-chat-section">
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="text-align:left">Source</th>
<th style="text-align:right">Sessions</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Total tokens</th>
<th style="text-align:right">Est. cost</th>
</tr>
</thead>
<tbody id="usage-chat-tbody">
<tr><td colspan="6" style="text-align:center;color:var(--text-dim);padding:24px">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
<p id="usage-no-cost-note" style="font-size:12px;color:var(--text-dim);margin-top:12px;display:none">
Cost estimates only available for runs recorded after the usage tracking feature was enabled.
Runs on free or unknown models show no cost.
</p>
</div>
{% endblock %}