diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/automation/create.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/automation/create.py
index 8e841c1e9..62d39fcf2 100644
--- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/automation/create.py
+++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/automation/create.py
@@ -32,8 +32,7 @@ from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated impo
)
from app.automations.schemas.api import AutomationCreate
from app.automations.services.automation import AutomationService
-from app.automations.services.model_policy import get_automation_model_eligibility
-from app.db import SearchSpace, User, async_session_maker
+from app.db import User, async_session_maker
from app.utils.content_utils import extract_text_content
from .prompt import build_draft_prompt
@@ -99,26 +98,11 @@ def create_create_automation_tool(
declined. Acknowledge once and stop — do NOT retry or pitch
variants without a fresh user request.
"""
- # --- 0. Eligibility gate (fail fast, before drafting + HITL) ---
- # Automations may only use premium or BYOK models. Check up front so we
- # don't make the user draft + approve a card that can't be saved.
- async with async_session_maker() as session:
- search_space = await session.get(SearchSpace, search_space_id)
- if search_space is None:
- return {
- "status": "error",
- "message": "search space not found in this session",
- }
- eligibility = get_automation_model_eligibility(search_space)
- if not eligibility["allowed"]:
- reasons = " ".join(v["reason"] for v in eligibility["violations"])
- return {
- "status": "error",
- "message": (
- f"{reasons} Update the search space's model settings to a "
- "premium or your own (BYOK) model, then try again."
- ),
- }
+ # Models are chosen per-automation on the approval card (premium/BYOK
+ # selectors) and validated when persisted by ``AutomationService.create``
+ # — so there's no fail-fast search-space eligibility gate here. The
+ # search space's current chat/role model selection no longer constrains
+ # whether an automation can be drafted or saved.
# --- 1. Draft via sub-LLM ---
prompt = build_draft_prompt(search_space_id=search_space_id, intent=intent)
diff --git a/surfsense_backend/app/automations/services/automation.py b/surfsense_backend/app/automations/services/automation.py
index 6c602d886..23dcd116f 100644
--- a/surfsense_backend/app/automations/services/automation.py
+++ b/surfsense_backend/app/automations/services/automation.py
@@ -22,6 +22,7 @@ from app.automations.schemas.definition.envelope import AutomationModels
from app.automations.services.model_policy import (
AutomationModelPolicyError,
assert_automation_models_billable,
+ assert_models_billable,
get_automation_model_eligibility,
)
from app.automations.triggers import get_trigger
@@ -43,16 +44,23 @@ 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,
- )
+ # Capture the model profile onto the definition so runs are insulated
+ # from later chat/search-space model changes. Two sources:
+ # 1. Explicit per-automation selection in ``payload.definition.models``
+ # (manual builder + chat approval card). Validate the chosen ids.
+ # 2. Fallback (no selection): snapshot the search space's current prefs.
+ # Either way the captured ids are guaranteed billable (premium/BYOK).
+ selected_models = payload.definition.models
+ if selected_models is not None:
+ self._assert_selected_models_billable(selected_models)
+ else:
+ search_space = await self._assert_models_billable(payload.search_space_id)
+ 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,
@@ -122,13 +130,22 @@ class AutomationService:
automation.status = data["status"]
if "definition" in data:
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``.
+ # Model snapshot handling on edit:
+ # * absent in the patch -> preserve the captured snapshot
+ # (a non-model definition change never silently re-binds the
+ # automation to the current chat/search-space selection).
+ # * unchanged from the snapshot -> keep as-is, no re-validation
+ # (so editing an automation whose captured model later drifted
+ # out of premium isn't blocked by an unrelated name/schedule edit).
+ # * genuinely changed -> validate the new selection (422 on a
+ # non-billable pick), then accept it.
existing_models = (automation.definition or {}).get("models")
- if existing_models is not None:
- new_def["models"] = existing_models
+ provided_models = new_def.get("models")
+ if provided_models is None:
+ if existing_models is not None:
+ new_def["models"] = existing_models
+ elif provided_models != existing_models:
+ self._assert_selected_models_billable(patch.definition.models)
automation.definition = new_def
automation.version += 1
@@ -199,6 +216,22 @@ class AutomationService:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return search_space
+ def _assert_selected_models_billable(self, models: AutomationModels) -> None:
+ """Reject creation when an explicitly selected model isn't billable.
+
+ Used when the client supplies ``definition.models`` (per-automation
+ selection from the builder or chat approval card). Same policy as the
+ search-space path: premium global or BYOK only, no free/Auto.
+ """
+ try:
+ assert_models_billable(
+ agent_llm_id=models.agent_llm_id,
+ image_generation_config_id=models.image_generation_config_id,
+ vision_llm_config_id=models.vision_llm_config_id,
+ )
+ except AutomationModelPolicyError as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+
async def _authorize(self, search_space_id: int, permission: str) -> None:
await check_permission(
self.session,
diff --git a/surfsense_backend/tests/unit/automations/services/test_automation_service_policy.py b/surfsense_backend/tests/unit/automations/services/test_automation_service_policy.py
index d81302380..0bbff39dc 100644
--- a/surfsense_backend/tests/unit/automations/services/test_automation_service_policy.py
+++ b/surfsense_backend/tests/unit/automations/services/test_automation_service_policy.py
@@ -16,7 +16,10 @@ from fastapi import HTTPException
import app.automations.services.automation as automation_mod
from app.automations.schemas.api import AutomationCreate, AutomationUpdate
-from app.automations.schemas.definition.envelope import AutomationDefinition
+from app.automations.schemas.definition.envelope import (
+ AutomationDefinition,
+ AutomationModels,
+)
from app.automations.schemas.definition.plan_step import PlanStep
from app.automations.services.automation import AutomationService
from app.automations.services.model_policy import AutomationModelPolicyError
@@ -175,6 +178,100 @@ async def test_create_treats_unset_prefs_as_auto_zero(
}
+async def test_create_honors_selected_models_when_provided(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """When the payload carries ``definition.models`` they are validated + kept.
+
+ The search-space snapshot path is bypassed entirely (no
+ ``assert_automation_models_billable`` call).
+ """
+
+ def _fail_snapshot(_ss):
+ raise AssertionError("snapshot path should not run when models are provided")
+
+ monkeypatch.setattr(
+ automation_mod, "assert_automation_models_billable", _fail_snapshot
+ )
+ validated: dict[str, Any] = {}
+
+ def _assert_ok(*, agent_llm_id, image_generation_config_id, vision_llm_config_id):
+ validated["ids"] = (
+ agent_llm_id,
+ image_generation_config_id,
+ vision_llm_config_id,
+ )
+
+ monkeypatch.setattr(automation_mod, "assert_models_billable", _assert_ok)
+
+ async def _noop_authorize(self, *_a, **_k):
+ return None
+
+ async def _return_added(self, _aid):
+ return self.session.added[-1]
+
+ monkeypatch.setattr(AutomationService, "_authorize", _noop_authorize)
+ monkeypatch.setattr(AutomationService, "_get_with_triggers_or_raise", _return_added)
+
+ service = _service(SimpleNamespace(agent_llm_id=-99))
+ payload = AutomationCreate(
+ search_space_id=1,
+ name="A",
+ definition=_definition(
+ models=AutomationModels(
+ agent_llm_id=-1,
+ image_generation_config_id=7,
+ vision_llm_config_id=-2,
+ )
+ ),
+ )
+
+ automation = await service.create(payload)
+
+ assert validated["ids"] == (-1, 7, -2)
+ assert automation.definition["models"] == {
+ "agent_llm_id": -1,
+ "image_generation_config_id": 7,
+ "vision_llm_config_id": -2,
+ }
+
+
+async def test_create_rejects_unbillable_selected_models(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A non-billable explicit selection maps the policy error to HTTP 422."""
+
+ def _raise(*, agent_llm_id, image_generation_config_id, vision_llm_config_id):
+ raise AutomationModelPolicyError(
+ [{"kind": "llm", "config_id": -3, "reason": "free model"}]
+ )
+
+ monkeypatch.setattr(automation_mod, "assert_models_billable", _raise)
+
+ async def _noop_authorize(self, *_a, **_k):
+ return None
+
+ monkeypatch.setattr(AutomationService, "_authorize", _noop_authorize)
+
+ service = _service(SimpleNamespace(agent_llm_id=-3))
+ payload = AutomationCreate(
+ search_space_id=1,
+ name="A",
+ definition=_definition(
+ models=AutomationModels(
+ agent_llm_id=-3,
+ image_generation_config_id=7,
+ vision_llm_config_id=-2,
+ )
+ ),
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ await service.create(payload)
+
+ assert exc_info.value.status_code == 422
+
+
async def test_update_preserves_captured_models(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -211,6 +308,166 @@ async def test_update_preserves_captured_models(
assert result.version == 4
+async def test_update_honors_changed_models_when_valid(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A definition edit with a *changed* models block validates + keeps it."""
+ existing = SimpleNamespace(
+ search_space_id=1,
+ definition={
+ "name": "A",
+ "plan": [],
+ "models": {
+ "agent_llm_id": -1,
+ "image_generation_config_id": 5,
+ "vision_llm_config_id": -1,
+ },
+ },
+ version=3,
+ )
+ validated: dict[str, Any] = {}
+
+ def _assert_ok(*, agent_llm_id, image_generation_config_id, vision_llm_config_id):
+ validated["ids"] = (
+ agent_llm_id,
+ image_generation_config_id,
+ vision_llm_config_id,
+ )
+
+ monkeypatch.setattr(automation_mod, "assert_models_billable", _assert_ok)
+
+ async def _noop_authorize(self, *_a, **_k):
+ return None
+
+ async def _return_existing(self, _aid):
+ return existing
+
+ monkeypatch.setattr(AutomationService, "_authorize", _noop_authorize)
+ monkeypatch.setattr(
+ AutomationService, "_get_with_triggers_or_raise", _return_existing
+ )
+
+ service = _service(SimpleNamespace())
+ patch = AutomationUpdate(
+ definition=_definition(
+ models=AutomationModels(
+ agent_llm_id=-2,
+ image_generation_config_id=9,
+ vision_llm_config_id=-2,
+ )
+ )
+ )
+
+ result = await service.update(7, patch)
+
+ assert validated["ids"] == (-2, 9, -2)
+ assert result.definition["models"] == {
+ "agent_llm_id": -2,
+ "image_generation_config_id": 9,
+ "vision_llm_config_id": -2,
+ }
+ assert result.version == 4
+
+
+async def test_update_rejects_changed_unbillable_models(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A *changed* non-billable models block is rejected with HTTP 422."""
+ existing = SimpleNamespace(
+ search_space_id=1,
+ definition={
+ "name": "A",
+ "plan": [],
+ "models": {
+ "agent_llm_id": -1,
+ "image_generation_config_id": 5,
+ "vision_llm_config_id": -1,
+ },
+ },
+ version=3,
+ )
+
+ def _raise(*, agent_llm_id, image_generation_config_id, vision_llm_config_id):
+ raise AutomationModelPolicyError(
+ [{"kind": "llm", "config_id": -7, "reason": "free model"}]
+ )
+
+ monkeypatch.setattr(automation_mod, "assert_models_billable", _raise)
+
+ async def _noop_authorize(self, *_a, **_k):
+ return None
+
+ async def _return_existing(self, _aid):
+ return existing
+
+ monkeypatch.setattr(AutomationService, "_authorize", _noop_authorize)
+ monkeypatch.setattr(
+ AutomationService, "_get_with_triggers_or_raise", _return_existing
+ )
+
+ service = _service(SimpleNamespace())
+ patch = AutomationUpdate(
+ definition=_definition(
+ models=AutomationModels(
+ agent_llm_id=-7,
+ image_generation_config_id=5,
+ vision_llm_config_id=-1,
+ )
+ )
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ await service.update(7, patch)
+
+ assert exc_info.value.status_code == 422
+
+
+async def test_update_keeps_unchanged_models_without_revalidation(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """An unchanged models block is kept as-is and is NOT re-validated.
+
+ Lets users edit an automation whose captured model later drifted out of
+ premium without an unrelated edit tripping the policy check.
+ """
+ captured = {
+ "agent_llm_id": -1,
+ "image_generation_config_id": 5,
+ "vision_llm_config_id": -1,
+ }
+ existing = SimpleNamespace(
+ search_space_id=1,
+ definition={"name": "A", "plan": [], "models": captured},
+ version=3,
+ )
+
+ def _fail(*_a, **_k):
+ raise AssertionError("unchanged models must not be re-validated")
+
+ monkeypatch.setattr(automation_mod, "assert_models_billable", _fail)
+
+ async def _noop_authorize(self, *_a, **_k):
+ return None
+
+ async def _return_existing(self, _aid):
+ return existing
+
+ monkeypatch.setattr(AutomationService, "_authorize", _noop_authorize)
+ monkeypatch.setattr(
+ AutomationService, "_get_with_triggers_or_raise", _return_existing
+ )
+
+ service = _service(SimpleNamespace())
+ patch = AutomationUpdate(
+ definition=_definition(models=AutomationModels(**captured))
+ )
+
+ result = await service.update(7, patch)
+
+ assert result.definition["models"] == captured
+ assert result.version == 4
+
+
async def test_model_eligibility_authorizes_and_returns_payload(
monkeypatch: pytest.MonkeyPatch,
) -> None:
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx
index 6bbe55ec9..756221d38 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx
@@ -1,8 +1,6 @@
"use client";
import { ShieldAlert } from "lucide-react";
-import { useAutomationModelEligibility } from "@/hooks/use-automation-model-eligibility";
import { useAutomations } from "@/hooks/use-automations";
-import { AutomationModelGateAlert } from "./components/automation-model-gate-alert";
import { AutomationsEmptyState } from "./components/automations-empty-state";
import { AutomationsHeader } from "./components/automations-header";
import { AutomationsTable } from "./components/automations-table";
@@ -24,18 +22,6 @@ interface AutomationsContentProps {
export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
const { automations, total, loading, error } = useAutomations();
const perms = useAutomationPermissions();
- // Gate creation on billable models (premium/BYOK). Only meaningful for
- // users who can create; the eligibility query loads in parallel.
- const { data: eligibility, isLoading: eligibilityLoading } = useAutomationModelEligibility(
- perms.canCreate ? searchSpaceId : undefined
- );
- const modelViolations = eligibility?.violations ?? [];
- // Disable create CTAs while loading (avoid a flash of enabled buttons) and
- // when the resolved models aren't billable.
- const createDisabled = perms.canCreate && (eligibilityLoading || modelViolations.length > 0);
- const disabledReason = eligibilityLoading
- ? "Checking model eligibility…"
- : modelViolations[0]?.reason;
if (perms.loading) {
// Permissions gate the entire page; defer everything until we know.
@@ -91,11 +77,7 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
canCreate={perms.canCreate}
showCreateCta={false}
/>
-
+
>
);
}
@@ -107,12 +89,7 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
total={total}
loading={loading}
canCreate={perms.canCreate}
- createDisabled={createDisabled}
- disabledReason={disabledReason}
/>
- {modelViolations.length > 0 && (
-
- )}
= {
- llm: "Agent model",
- image: "Image model",
- vision: "Vision model",
-};
-
-/**
- * Warns that the search space's models aren't billable for automations.
- *
- * Automations may only use premium global models or your own (BYOK) models —
- * free models and Auto mode are blocked so every run is metered in premium
- * credits. Surfaced wherever a user can start creating an automation.
- */
-export function AutomationModelGateAlert({
- searchSpaceId,
- violations,
- className,
-}: AutomationModelGateAlertProps) {
- if (violations.length === 0) return null;
-
- return (
-
-
- Automations need a premium or your own model
-
-
- Automations run unattended, so every run must use a premium model or your own (BYOK)
- model. Update these in your model settings, then create your automation.
-
-
-
- );
-}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx
index ee7dadce6..cc54c5e94 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx
@@ -2,14 +2,10 @@
import { MessageSquarePlus, SquarePen, Workflow } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
-import type { ModelEligibilityViolation } from "@/contracts/types/automation.types";
-import { AutomationModelGateAlert } from "./automation-model-gate-alert";
interface AutomationsEmptyStateProps {
searchSpaceId: number;
canCreate: boolean;
- /** Model slots that block creation (free/Auto). Empty when eligible. */
- modelViolations?: ModelEligibilityViolation[];
}
/**
@@ -18,13 +14,7 @@ interface AutomationsEmptyStateProps {
* "new automation" form. We surface the chat path explicitly so users
* don't go hunting for an "add" button that doesn't exist.
*/
-export function AutomationsEmptyState({
- searchSpaceId,
- canCreate,
- modelViolations = [],
-}: AutomationsEmptyStateProps) {
- const modelsBlocked = modelViolations.length > 0;
-
+export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsEmptyStateProps) {
return (
@@ -36,26 +26,20 @@ export function AutomationsEmptyState({
SurfSense drafts the automation for your approval.
{canCreate ? (
- modelsBlocked ? (
-
-
-
- ) : (
-
-
-
-
- )
+
+
+
+
) : (
You don't have permission to create automations in this search space.
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx
index 308eaccfb..137727f60 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx
@@ -1,9 +1,7 @@
"use client";
import { MessageSquarePlus, SquarePen } from "lucide-react";
import Link from "next/link";
-import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface AutomationsHeaderProps {
searchSpaceId: number;
@@ -16,22 +14,13 @@ interface AutomationsHeaderProps {
* there to avoid a duplicate button.
*/
showCreateCta?: boolean;
- /**
- * Disable the create CTAs when the search space's models aren't billable
- * for automations (free/Auto). When set, a tooltip explains why and the
- * buttons render disabled rather than as links.
- */
- createDisabled?: boolean;
- disabledReason?: string;
}
-const DEFAULT_DISABLED_REASON =
- "Automations need a premium or your own (BYOK) model. Update your model settings to enable.";
-
/**
* Page header: title + count + "Create via chat" CTA. Creation is intent-driven
* (the create_automation tool runs inside chat with a HITL approval card), so
- * the CTA links to a new chat rather than opening a form.
+ * the CTA links to a new chat rather than opening a form. Model eligibility is
+ * handled per-automation in the builder + approval card, not gated here.
*/
export function AutomationsHeader({
searchSpaceId,
@@ -39,8 +28,6 @@ export function AutomationsHeader({
loading,
canCreate,
showCreateCta = true,
- createDisabled = false,
- disabledReason,
}: AutomationsHeaderProps) {
return (
@@ -54,70 +41,20 @@ export function AutomationsHeader({