mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
feat(automations): implement model eligibility checks for automation creation
- Added model eligibility checks to ensure automations can only use billable models (premium or BYOK). - Introduced new API endpoint to report model eligibility status for search spaces. - Updated frontend components to display eligibility alerts and disable creation options when models are not billable. - Enhanced automation creation forms to reflect model eligibility, preventing users from submitting invalid configurations. - Implemented server-side logic to capture and preserve model preferences across automation edits, ensuring consistent behavior during execution.
This commit is contained in:
parent
5d90fbe99f
commit
409fec94c3
32 changed files with 1451 additions and 67 deletions
|
|
@ -3,14 +3,26 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .automation import AutomationService, get_automation_service
|
||||
from .model_policy import (
|
||||
AutomationModelPolicyError,
|
||||
assert_automation_models_billable,
|
||||
assert_models_billable,
|
||||
get_automation_model_eligibility,
|
||||
get_model_eligibility,
|
||||
)
|
||||
from .run import RunService, get_run_service
|
||||
from .trigger import TriggerService, get_trigger_service
|
||||
|
||||
__all__ = [
|
||||
"AutomationModelPolicyError",
|
||||
"AutomationService",
|
||||
"RunService",
|
||||
"TriggerService",
|
||||
"assert_automation_models_billable",
|
||||
"assert_models_billable",
|
||||
"get_automation_model_eligibility",
|
||||
"get_automation_service",
|
||||
"get_model_eligibility",
|
||||
"get_run_service",
|
||||
"get_trigger_service",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -18,9 +18,15 @@ from app.automations.schemas.api import (
|
|||
AutomationUpdate,
|
||||
TriggerCreate,
|
||||
)
|
||||
from app.automations.schemas.definition.envelope import AutomationModels
|
||||
from app.automations.services.model_policy import (
|
||||
AutomationModelPolicyError,
|
||||
assert_automation_models_billable,
|
||||
get_automation_model_eligibility,
|
||||
)
|
||||
from app.automations.triggers import get_trigger
|
||||
from app.automations.triggers.schedule import compute_next_fire_at
|
||||
from app.db import Permission, User, get_async_session
|
||||
from app.db import Permission, SearchSpace, User, get_async_session
|
||||
from app.users import current_active_user
|
||||
from app.utils.rbac import check_permission
|
||||
|
||||
|
|
@ -37,6 +43,16 @@ class AutomationService:
|
|||
await self._authorize(
|
||||
payload.search_space_id, Permission.AUTOMATIONS_CREATE.value
|
||||
)
|
||||
search_space = await self._assert_models_billable(payload.search_space_id)
|
||||
|
||||
# Snapshot the search space's current (already-validated) model prefs onto
|
||||
# the definition so runs are insulated from later chat/search-space model
|
||||
# changes. Captured ids are guaranteed billable by the check above.
|
||||
payload.definition.models = AutomationModels(
|
||||
agent_llm_id=search_space.agent_llm_id or 0,
|
||||
image_generation_config_id=search_space.image_generation_config_id or 0,
|
||||
vision_llm_config_id=search_space.vision_llm_config_id or 0,
|
||||
)
|
||||
|
||||
automation = Automation(
|
||||
search_space_id=payload.search_space_id,
|
||||
|
|
@ -105,9 +121,15 @@ class AutomationService:
|
|||
if "status" in data:
|
||||
automation.status = data["status"]
|
||||
if "definition" in data:
|
||||
automation.definition = patch.definition.model_dump(
|
||||
mode="json", by_alias=True
|
||||
)
|
||||
new_def = patch.definition.model_dump(mode="json", by_alias=True)
|
||||
# Preserve the captured model snapshot across edits so a definition
|
||||
# change never silently re-binds the automation to the current chat
|
||||
# model selection. Backend-managed; survives whether or not the
|
||||
# client round-trips ``models``.
|
||||
existing_models = (automation.definition or {}).get("models")
|
||||
if existing_models is not None:
|
||||
new_def["models"] = existing_models
|
||||
automation.definition = new_def
|
||||
automation.version += 1
|
||||
|
||||
await self.session.commit()
|
||||
|
|
@ -143,6 +165,40 @@ class AutomationService:
|
|||
)
|
||||
return automation
|
||||
|
||||
async def model_eligibility(self, *, search_space_id: int) -> dict:
|
||||
"""Return whether a search space's models are billable for automations.
|
||||
|
||||
``{"allowed": bool, "violations": [{kind, config_id, reason}, ...]}``.
|
||||
"""
|
||||
await self._authorize(search_space_id, Permission.AUTOMATIONS_READ.value)
|
||||
search_space = await self.session.get(SearchSpace, search_space_id)
|
||||
if search_space is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"search space {search_space_id} not found"
|
||||
)
|
||||
return get_automation_model_eligibility(search_space)
|
||||
|
||||
async def _assert_models_billable(self, search_space_id: int) -> SearchSpace:
|
||||
"""Reject creation when the search space's models aren't billable.
|
||||
|
||||
Automations may only use premium global models or user BYOK models; free
|
||||
global models and Auto mode are blocked. Mirrors the runtime backstop in
|
||||
``agent_task`` so users can't save an automation that would fail to run.
|
||||
|
||||
Returns the loaded :class:`SearchSpace` so the caller can capture its
|
||||
model prefs without a second DB read.
|
||||
"""
|
||||
search_space = await self.session.get(SearchSpace, search_space_id)
|
||||
if search_space is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"search space {search_space_id} not found"
|
||||
)
|
||||
try:
|
||||
assert_automation_models_billable(search_space)
|
||||
except AutomationModelPolicyError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return search_space
|
||||
|
||||
async def _authorize(self, search_space_id: int, permission: str) -> None:
|
||||
await check_permission(
|
||||
self.session,
|
||||
|
|
|
|||
173
surfsense_backend/app/automations/services/model_policy.py
Normal file
173
surfsense_backend/app/automations/services/model_policy.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""Model-billing policy for automations.
|
||||
|
||||
Automations run unattended, so every run must be **billable**: it may only use
|
||||
either a premium global model (``billing_tier == "premium"``) or a user-provided
|
||||
BYOK model (a positive config id pointing at a per-user/per-space DB row). Free
|
||||
global models and Auto mode are blocked, because Auto can dispatch to a free
|
||||
deployment and free models aren't metered in premium credits.
|
||||
|
||||
Config id conventions (shared across chat / image / vision):
|
||||
- ``id == 0`` → Auto mode (``AUTO_MODE_ID`` / ``IMAGE_GEN_AUTO_MODE_ID`` /
|
||||
``VISION_AUTO_MODE_ID``). Blocked.
|
||||
- ``id < 0`` → global YAML/OpenRouter config. Allowed only if premium.
|
||||
- ``id > 0`` → user BYOK DB row. Always allowed.
|
||||
|
||||
This module is the single source of truth used by both creation-time enforcement
|
||||
(``AutomationService.create`` and the ``create_automation`` chat tool) and the
|
||||
runtime backstop (``agent_task`` dependencies).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.db import SearchSpace
|
||||
|
||||
ModelKind = Literal["llm", "image", "vision"]
|
||||
|
||||
_KIND_LABEL: dict[ModelKind, str] = {
|
||||
"llm": "agent LLM",
|
||||
"image": "image generation model",
|
||||
"vision": "vision model",
|
||||
}
|
||||
|
||||
|
||||
def _is_premium_global(kind: ModelKind, config_id: int) -> bool:
|
||||
"""Return True if a negative (global) config id is a premium tier model."""
|
||||
from app.config import config as app_config
|
||||
|
||||
cfg: dict | None = None
|
||||
if kind == "llm":
|
||||
from app.agents.new_chat.llm_config import load_global_llm_config_by_id
|
||||
|
||||
cfg = load_global_llm_config_by_id(config_id)
|
||||
elif kind == "image":
|
||||
cfg = next(
|
||||
(
|
||||
c
|
||||
for c in app_config.GLOBAL_IMAGE_GEN_CONFIGS
|
||||
if c.get("id") == config_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
else: # vision
|
||||
cfg = next(
|
||||
(
|
||||
c
|
||||
for c in app_config.GLOBAL_VISION_LLM_CONFIGS
|
||||
if c.get("id") == config_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not cfg:
|
||||
return False
|
||||
return str(cfg.get("billing_tier", "free")).lower() == "premium"
|
||||
|
||||
|
||||
def _classify(kind: ModelKind, config_id: int | None) -> tuple[bool, str]:
|
||||
"""Classify a resolved config id as allowed or blocked.
|
||||
|
||||
Returns ``(allowed, reason)``; ``reason`` is empty when allowed.
|
||||
"""
|
||||
label = _KIND_LABEL[kind]
|
||||
|
||||
if config_id is None or config_id == 0:
|
||||
return (
|
||||
False,
|
||||
f"The {label} is set to Auto mode. Automations require an explicit "
|
||||
"premium model or your own (BYOK) model so every run is billable.",
|
||||
)
|
||||
|
||||
if config_id > 0:
|
||||
# Positive id → user-owned BYOK config. Always allowed.
|
||||
return True, ""
|
||||
|
||||
# Negative id → global config. Allowed only if premium.
|
||||
if _is_premium_global(kind, config_id):
|
||||
return True, ""
|
||||
|
||||
return (
|
||||
False,
|
||||
f"The {label} is a free model. Automations can only use premium models "
|
||||
"or your own (BYOK) models so every run is billable.",
|
||||
)
|
||||
|
||||
|
||||
def get_model_eligibility(
|
||||
*,
|
||||
agent_llm_id: int | None,
|
||||
image_generation_config_id: int | None,
|
||||
vision_llm_config_id: int | None,
|
||||
) -> dict:
|
||||
"""Return ``{"allowed": bool, "violations": [...]}`` for explicit config ids.
|
||||
|
||||
The ID-based core shared by both the search-space path (creation/eligibility)
|
||||
and the captured-snapshot path (runtime backstop). Each violation is
|
||||
``{"kind", "config_id", "reason"}``.
|
||||
"""
|
||||
checks: list[tuple[ModelKind, int | None]] = [
|
||||
("llm", agent_llm_id),
|
||||
("image", image_generation_config_id),
|
||||
("vision", vision_llm_config_id),
|
||||
]
|
||||
|
||||
violations: list[dict] = []
|
||||
for kind, config_id in checks:
|
||||
allowed, reason = _classify(kind, config_id)
|
||||
if not allowed:
|
||||
violations.append({"kind": kind, "config_id": config_id, "reason": reason})
|
||||
|
||||
return {"allowed": not violations, "violations": violations}
|
||||
|
||||
|
||||
def get_automation_model_eligibility(search_space: SearchSpace) -> dict:
|
||||
"""Return ``{"allowed": bool, "violations": [...]}`` for a search space.
|
||||
|
||||
Used by the eligibility endpoint and the chat tool's early check. Thin
|
||||
wrapper over :func:`get_model_eligibility`.
|
||||
"""
|
||||
return get_model_eligibility(
|
||||
agent_llm_id=search_space.agent_llm_id,
|
||||
image_generation_config_id=search_space.image_generation_config_id,
|
||||
vision_llm_config_id=search_space.vision_llm_config_id,
|
||||
)
|
||||
|
||||
|
||||
class AutomationModelPolicyError(Exception):
|
||||
"""Raised when a search space's models are not billable for automations."""
|
||||
|
||||
def __init__(self, violations: list[dict]) -> None:
|
||||
self.violations = violations
|
||||
reasons = "; ".join(v["reason"] for v in violations)
|
||||
super().__init__(
|
||||
reasons or "Automations require premium or BYOK models for all model slots."
|
||||
)
|
||||
|
||||
|
||||
def assert_models_billable(
|
||||
*,
|
||||
agent_llm_id: int | None,
|
||||
image_generation_config_id: int | None,
|
||||
vision_llm_config_id: int | None,
|
||||
) -> None:
|
||||
"""Raise :class:`AutomationModelPolicyError` if any explicit id is not billable.
|
||||
|
||||
The ID-based core used by the runtime backstop against an automation's
|
||||
captured model snapshot.
|
||||
"""
|
||||
result = get_model_eligibility(
|
||||
agent_llm_id=agent_llm_id,
|
||||
image_generation_config_id=image_generation_config_id,
|
||||
vision_llm_config_id=vision_llm_config_id,
|
||||
)
|
||||
if not result["allowed"]:
|
||||
raise AutomationModelPolicyError(result["violations"])
|
||||
|
||||
|
||||
def assert_automation_models_billable(search_space: SearchSpace) -> None:
|
||||
"""Raise :class:`AutomationModelPolicyError` if any model slot is not billable."""
|
||||
result = get_automation_model_eligibility(search_space)
|
||||
if not result["allowed"]:
|
||||
raise AutomationModelPolicyError(result["violations"])
|
||||
Loading…
Add table
Add a link
Reference in a new issue