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:
DESKTOP-RTLN3BA\$punk 2026-05-29 03:13:46 -07:00
parent 5d90fbe99f
commit 409fec94c3
32 changed files with 1451 additions and 67 deletions

View file

@ -57,6 +57,7 @@ async def build_agent_with_cache(
mcp_tools_by_agent: dict[str, list[BaseTool]], mcp_tools_by_agent: dict[str, list[BaseTool]],
disabled_tools: list[str] | None, disabled_tools: list[str] | None,
config_id: str | None, config_id: str | None,
image_generation_config_id_override: int | None = None,
) -> Any: ) -> Any:
"""Compile the multi-agent graph, serving from cache when key components are stable.""" """Compile the multi-agent graph, serving from cache when key components are stable."""
@ -91,7 +92,7 @@ async def build_agent_with_cache(
# the key, otherwise a hit will leak state across threads. Bump the schema # the key, otherwise a hit will leak state across threads. Bump the schema
# version when the component list changes shape. # version when the component list changes shape.
cache_key = stable_hash( cache_key = stable_hash(
"multi-agent-v1", "multi-agent-v2",
config_id, config_id,
thread_id, thread_id,
user_id, user_id,
@ -109,6 +110,10 @@ async def build_agent_with_cache(
system_prompt_hash(final_system_prompt), system_prompt_hash(final_system_prompt),
max_input_tokens, max_input_tokens,
sorted(disabled_tools) if disabled_tools else None, sorted(disabled_tools) if disabled_tools else None,
# Bound into the generate_image subagent tool at construction time, so it
# must key the compiled-agent cache to avoid leaking one automation's
# image model into another with the same config_id/search_space.
image_generation_config_id_override,
) )
return await get_cache().get_or_build(cache_key, builder=_build) return await get_cache().get_or_build(cache_key, builder=_build)

View file

@ -62,8 +62,14 @@ async def create_multi_agent_chat_deep_agent(
mentioned_document_ids: list[int] | None = None, mentioned_document_ids: list[int] | None = None,
anon_session_id: str | None = None, anon_session_id: str | None = None,
filesystem_selection: FilesystemSelection | None = None, filesystem_selection: FilesystemSelection | None = None,
image_generation_config_id: int | None = None,
): ):
"""Deep agent with SurfSense tools/middleware; registry route subagents behind ``task`` when enabled.""" """Deep agent with SurfSense tools/middleware; registry route subagents behind ``task`` when enabled.
``image_generation_config_id`` overrides the search space's image model for
this invocation (used by automations to run on their captured model). When
``None``, the ``generate_image`` tool resolves the live search-space pref.
"""
_t_agent_total = time.perf_counter() _t_agent_total = time.perf_counter()
apply_litellm_prompt_caching(llm, agent_config=agent_config, thread_id=thread_id) apply_litellm_prompt_caching(llm, agent_config=agent_config, thread_id=thread_id)
@ -129,6 +135,9 @@ async def create_multi_agent_chat_deep_agent(
"available_document_types": available_document_types, "available_document_types": available_document_types,
"max_input_tokens": _max_input_tokens, "max_input_tokens": _max_input_tokens,
"llm": llm, "llm": llm,
# Per-invocation image model override (automations run on their captured
# model). Reaches the generate_image subagent tool via subagent_dependencies.
"image_generation_config_id_override": image_generation_config_id,
} }
_t0 = time.perf_counter() _t0 = time.perf_counter()
@ -285,6 +294,7 @@ async def create_multi_agent_chat_deep_agent(
mcp_tools_by_agent=mcp_tools_by_agent, mcp_tools_by_agent=mcp_tools_by_agent,
disabled_tools=disabled_tools, disabled_tools=disabled_tools,
config_id=config_id, config_id=config_id,
image_generation_config_id_override=image_generation_config_id,
) )
_perf_log.info( _perf_log.info(
"[create_agent] Middleware stack + graph compiled in %.3fs", "[create_agent] Middleware stack + graph compiled in %.3fs",

View file

@ -32,7 +32,8 @@ from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated impo
) )
from app.automations.schemas.api import AutomationCreate from app.automations.schemas.api import AutomationCreate
from app.automations.services.automation import AutomationService from app.automations.services.automation import AutomationService
from app.db import User, async_session_maker from app.automations.services.model_policy import get_automation_model_eligibility
from app.db import SearchSpace, User, async_session_maker
from app.utils.content_utils import extract_text_content from app.utils.content_utils import extract_text_content
from .prompt import build_draft_prompt from .prompt import build_draft_prompt
@ -98,6 +99,27 @@ def create_create_automation_tool(
declined. Acknowledge once and stop do NOT retry or pitch declined. Acknowledge once and stop do NOT retry or pitch
variants without a fresh user request. 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."
),
}
# --- 1. Draft via sub-LLM --- # --- 1. Draft via sub-LLM ---
prompt = build_draft_prompt(search_space_id=search_space_id, intent=intent) prompt = build_draft_prompt(search_space_id=search_space_id, intent=intent)
try: try:

View file

@ -63,8 +63,14 @@ def _get_global_image_gen_config(config_id: int) -> dict | None:
def create_generate_image_tool( def create_generate_image_tool(
search_space_id: int, search_space_id: int,
db_session: AsyncSession, db_session: AsyncSession,
image_generation_config_id_override: int | None = None,
): ):
"""Create ``generate_image`` with bound search space; DB work uses a per-call session.""" """Create ``generate_image`` with bound search space; DB work uses a per-call session.
``image_generation_config_id_override``: when set (automations running on a
captured model), use this config id instead of reading the search space's
live ``image_generation_config_id``.
"""
del db_session # use a fresh per-call session, see below del db_session # use a fresh per-call session, see below
@tool @tool
@ -108,19 +114,27 @@ def create_generate_image_tool(
# task's session is shared across every tool; without isolation, # task's session is shared across every tool; without isolation,
# autoflushes from a concurrent writer poison this tool too. # autoflushes from a concurrent writer poison this tool too.
async with shielded_async_session() as session: async with shielded_async_session() as session:
result = await session.execute( if image_generation_config_id_override is not None:
select(SearchSpace).filter(SearchSpace.id == search_space_id) # Automation run: use the captured image model, insulated from
) # later search-space changes. No search-space read needed.
search_space = result.scalars().first() config_id = (
if not search_space: image_generation_config_id_override or IMAGE_GEN_AUTO_MODE_ID
return _failed(
{"error": "Search space not found"},
error="Search space not found",
) )
else:
result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == search_space_id)
)
search_space = result.scalars().first()
if not search_space:
return _failed(
{"error": "Search space not found"},
error="Search space not found",
)
config_id = ( config_id = (
search_space.image_generation_config_id or IMAGE_GEN_AUTO_MODE_ID search_space.image_generation_config_id
) or IMAGE_GEN_AUTO_MODE_ID
)
# Build generation kwargs # Build generation kwargs
# NOTE: size, quality, and style are intentionally NOT passed. # NOTE: size, quality, and style are intentionally NOT passed.

View file

@ -51,5 +51,8 @@ def load_tools(
create_generate_image_tool( create_generate_image_tool(
search_space_id=d["search_space_id"], search_space_id=d["search_space_id"],
db_session=d["db_session"], db_session=d["db_session"],
image_generation_config_id_override=d.get(
"image_generation_config_id_override"
),
), ),
] ]

View file

@ -8,6 +8,12 @@ from typing import Any
from langgraph.checkpoint.memory import InMemorySaver from langgraph.checkpoint.memory import InMemorySaver
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.automations.services.model_policy import (
AutomationModelPolicyError,
assert_automation_models_billable,
assert_models_billable,
)
from app.db import SearchSpace
from app.tasks.chat.streaming.flows.shared.llm_bundle import load_llm_bundle from app.tasks.chat.streaming.flows.shared.llm_bundle import load_llm_bundle
from app.tasks.chat.streaming.flows.shared.pre_stream_setup import ( from app.tasks.chat.streaming.flows.shared.pre_stream_setup import (
setup_connector_and_firecrawl, setup_connector_and_firecrawl,
@ -33,17 +39,48 @@ async def build_dependencies(
*, *,
session: AsyncSession, session: AsyncSession,
search_space_id: int, search_space_id: int,
agent_llm_id: int | None = None,
image_generation_config_id: int | None = None,
vision_llm_config_id: int | None = None,
) -> AgentDependencies: ) -> AgentDependencies:
"""Load the LLM bundle, connector service, and a per-invoke in-memory checkpointer. """Load the LLM bundle, connector service, and a per-invoke in-memory checkpointer.
Uses the search space's default LLM config (``config_id=-1``). Per-step Resolves the agent LLM from the automation's *captured* model snapshot
model overrides land in a future iteration alongside the ``model`` param. (``agent_llm_id``) so runs are insulated from later chat/search-space model
changes. The model policy is enforced here as a runtime backstop: a captured
model that is no longer billable (e.g. a premium global config was removed)
fails the run clearly instead of silently consuming a free model.
When ``agent_llm_id`` is ``None`` (no captured snapshot defensive fallback),
fall back to the live search space's ``agent_llm_id`` and validate that.
""" """
if agent_llm_id is not None:
try:
assert_models_billable(
agent_llm_id=agent_llm_id,
image_generation_config_id=image_generation_config_id,
vision_llm_config_id=vision_llm_config_id,
)
except AutomationModelPolicyError as exc:
raise DependencyError(str(exc)) from exc
resolved_agent_llm_id = agent_llm_id or 0
else:
search_space = await session.get(SearchSpace, search_space_id)
if search_space is None:
raise DependencyError(f"search space {search_space_id} not found")
try:
assert_automation_models_billable(search_space)
except AutomationModelPolicyError as exc:
raise DependencyError(str(exc)) from exc
resolved_agent_llm_id = search_space.agent_llm_id or 0
llm, agent_config, err = await load_llm_bundle( llm, agent_config, err = await load_llm_bundle(
session, config_id=-1, search_space_id=search_space_id session,
config_id=resolved_agent_llm_id,
search_space_id=search_space_id,
) )
if err is not None or llm is None: if err is not None or llm is None:
raise DependencyError(err or "failed to load default LLM config") raise DependencyError(err or "failed to load agent LLM config")
connector_service, firecrawl_api_key = await setup_connector_and_firecrawl( connector_service, firecrawl_api_key = await setup_connector_and_firecrawl(
session, search_space_id=search_space_id session, search_space_id=search_space_id

View file

@ -147,6 +147,9 @@ async def run_agent_task(
deps = await build_dependencies( deps = await build_dependencies(
session=agent_session, session=agent_session,
search_space_id=ctx.search_space_id, search_space_id=ctx.search_space_id,
agent_llm_id=ctx.agent_llm_id,
image_generation_config_id=ctx.image_generation_config_id,
vision_llm_config_id=ctx.vision_llm_config_id,
) )
agent = await create_multi_agent_chat_deep_agent( agent = await create_multi_agent_chat_deep_agent(
@ -161,6 +164,7 @@ async def run_agent_task(
firecrawl_api_key=deps.firecrawl_api_key, firecrawl_api_key=deps.firecrawl_api_key,
thread_visibility=ChatVisibility.PRIVATE, thread_visibility=ChatVisibility.PRIVATE,
mentioned_document_ids=mentioned_document_ids, mentioned_document_ids=mentioned_document_ids,
image_generation_config_id=ctx.image_generation_config_id,
) )
agent_query, runtime_context = await _resolve_mention_context( agent_query, runtime_context = await _resolve_mention_context(

View file

@ -20,6 +20,12 @@ class ActionContext:
step_id: str step_id: str
search_space_id: int search_space_id: int
creator_user_id: UUID | None creator_user_id: UUID | None
# Captured model snapshot from the automation definition (``definition.models``),
# resolved per run instead of the live search space. ``None`` falls back to the
# search space's current prefs (defensive; should not happen post-capture).
agent_llm_id: int | None = None
image_generation_config_id: int | None = None
vision_llm_config_id: int | None = None
ActionHandler = Callable[[dict[str, Any]], Awaitable[Any]] ActionHandler = Callable[[dict[str, Any]], Awaitable[Any]]

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, Query, status from fastapi import APIRouter, Depends, Query, status
from pydantic import BaseModel
from app.automations.schemas.api import ( from app.automations.schemas.api import (
AutomationCreate, AutomationCreate,
@ -16,6 +17,17 @@ from app.automations.services import AutomationService, get_automation_service
router = APIRouter() router = APIRouter()
class ModelEligibilityViolation(BaseModel):
kind: str
config_id: int | None
reason: str
class ModelEligibility(BaseModel):
allowed: bool
violations: list[ModelEligibilityViolation]
@router.post( @router.post(
"/automations", "/automations",
response_model=AutomationDetail, response_model=AutomationDetail,
@ -47,6 +59,23 @@ async def list_automations(
) )
@router.get("/automations/model-eligibility", response_model=ModelEligibility)
async def get_automation_model_eligibility(
search_space_id: int = Query(...),
service: AutomationService = Depends(get_automation_service),
) -> ModelEligibility:
"""Report whether a search space's models are billable for automations.
Used by the frontend to gate creation: automations may only use premium
global models or user BYOK models (free models and Auto mode are blocked).
NOTE: declared before ``/automations/{automation_id}`` so the literal path
isn't captured by the int-typed ``{automation_id}`` route.
"""
result = await service.model_eligibility(search_space_id=search_space_id)
return ModelEligibility.model_validate(result)
@router.get("/automations/{automation_id}", response_model=AutomationDetail) @router.get("/automations/{automation_id}", response_model=AutomationDetail)
async def get_automation( async def get_automation(
automation_id: int, automation_id: int,

View file

@ -9,7 +9,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.automations.actions.types import ActionContext from app.automations.actions.types import ActionContext
from app.automations.persistence.enums.run_status import RunStatus from app.automations.persistence.enums.run_status import RunStatus
from app.automations.persistence.models.run import AutomationRun from app.automations.persistence.models.run import AutomationRun
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.schemas.definition.plan_step import PlanStep
from app.automations.templating import build_run_context from app.automations.templating import build_run_context
@ -47,7 +50,7 @@ async def execute_run(session: AsyncSession, run_id: int) -> None:
for step in definition.plan: for step in definition.plan:
template_ctx = _build_template_ctx(run, step_outputs) template_ctx = _build_template_ctx(run, step_outputs)
action_ctx = _build_action_ctx(session, run, step) action_ctx = _build_action_ctx(session, run, step, definition.models)
result = await execute_step( result = await execute_step(
step=step, step=step,
template_context=template_ctx, template_context=template_ctx,
@ -82,7 +85,7 @@ async def _run_on_failure(
return return
template_ctx = _build_template_ctx(run, step_outputs={}) template_ctx = _build_template_ctx(run, step_outputs={})
for step in definition.execution.on_failure: for step in definition.execution.on_failure:
action_ctx = _build_action_ctx(session, run, step) action_ctx = _build_action_ctx(session, run, step, definition.models)
result = await execute_step( result = await execute_step(
step=step, step=step,
template_context=template_ctx, template_context=template_ctx,
@ -117,7 +120,10 @@ def _build_template_ctx(
def _build_action_ctx( def _build_action_ctx(
session: AsyncSession, run: AutomationRun, step: PlanStep session: AsyncSession,
run: AutomationRun,
step: PlanStep,
models: AutomationModels | None,
) -> ActionContext: ) -> ActionContext:
automation = run.automation automation = run.automation
return ActionContext( return ActionContext(
@ -126,4 +132,9 @@ def _build_action_ctx(
step_id=step.step_id, step_id=step.step_id,
search_space_id=automation.search_space_id, search_space_id=automation.search_space_id,
creator_user_id=automation.created_by_user_id, creator_user_id=automation.created_by_user_id,
agent_llm_id=models.agent_llm_id if models else None,
image_generation_config_id=(
models.image_generation_config_id if models else None
),
vision_llm_config_id=models.vision_llm_config_id if models else None,
) )

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from .envelope import AutomationDefinition from .envelope import AutomationDefinition, AutomationModels
from .execution import Execution from .execution import Execution
from .inputs import Inputs from .inputs import Inputs
from .metadata import Metadata from .metadata import Metadata
@ -11,6 +11,7 @@ from .trigger_spec import TriggerSpec
__all__ = [ __all__ = [
"AutomationDefinition", "AutomationDefinition",
"AutomationModels",
"Execution", "Execution",
"Inputs", "Inputs",
"Metadata", "Metadata",

View file

@ -11,6 +11,21 @@ from .plan_step import PlanStep
from .trigger_spec import TriggerSpec from .trigger_spec import TriggerSpec
class AutomationModels(BaseModel):
"""Captured model profile for an automation.
Snapshotted from the search space's preferences at create time so runs are
insulated from later chat/search-space model changes. Config-id conventions
match the shared scheme (``0`` Auto, ``< 0`` global, ``> 0`` BYOK).
"""
model_config = ConfigDict(extra="forbid")
agent_llm_id: int = 0
image_generation_config_id: int = 0
vision_llm_config_id: int = 0
class AutomationDefinition(BaseModel): class AutomationDefinition(BaseModel):
"""Top-level shape of an automation.""" """Top-level shape of an automation."""
@ -24,3 +39,7 @@ class AutomationDefinition(BaseModel):
plan: list[PlanStep] = Field(..., min_length=1) plan: list[PlanStep] = Field(..., min_length=1)
execution: Execution = Field(default_factory=Execution) execution: Execution = Field(default_factory=Execution)
metadata: Metadata = Field(default_factory=Metadata) metadata: Metadata = Field(default_factory=Metadata)
# Captured server-side at create() and preserved across update(); resolved
# at runtime instead of the live search space. Optional so drafts/builder
# payloads validate without it.
models: AutomationModels | None = None

View file

@ -3,14 +3,26 @@
from __future__ import annotations from __future__ import annotations
from .automation import AutomationService, get_automation_service 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 .run import RunService, get_run_service
from .trigger import TriggerService, get_trigger_service from .trigger import TriggerService, get_trigger_service
__all__ = [ __all__ = [
"AutomationModelPolicyError",
"AutomationService", "AutomationService",
"RunService", "RunService",
"TriggerService", "TriggerService",
"assert_automation_models_billable",
"assert_models_billable",
"get_automation_model_eligibility",
"get_automation_service", "get_automation_service",
"get_model_eligibility",
"get_run_service", "get_run_service",
"get_trigger_service", "get_trigger_service",
] ]

View file

@ -18,9 +18,15 @@ from app.automations.schemas.api import (
AutomationUpdate, AutomationUpdate,
TriggerCreate, 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 import get_trigger
from app.automations.triggers.schedule import compute_next_fire_at 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.users import current_active_user
from app.utils.rbac import check_permission from app.utils.rbac import check_permission
@ -37,6 +43,16 @@ class AutomationService:
await self._authorize( await self._authorize(
payload.search_space_id, Permission.AUTOMATIONS_CREATE.value 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( automation = Automation(
search_space_id=payload.search_space_id, search_space_id=payload.search_space_id,
@ -105,9 +121,15 @@ class AutomationService:
if "status" in data: if "status" in data:
automation.status = data["status"] automation.status = data["status"]
if "definition" in data: if "definition" in data:
automation.definition = patch.definition.model_dump( new_def = patch.definition.model_dump(mode="json", by_alias=True)
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 automation.version += 1
await self.session.commit() await self.session.commit()
@ -143,6 +165,40 @@ class AutomationService:
) )
return automation 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: async def _authorize(self, search_space_id: int, permission: str) -> None:
await check_permission( await check_permission(
self.session, self.session,

View 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"])

View file

@ -0,0 +1,174 @@
"""Lock the runtime model-policy backstop in ``build_dependencies``.
Automations resolve their LLM from the *captured* ``agent_llm_id`` snapshot (so
runs are insulated from later chat/search-space model changes), and the model
policy is re-checked at run time so a captured model that is no longer billable
fails the run clearly. When no snapshot is present, resolution falls back to the
live search space.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
import app.automations.actions.agent_task.dependencies as deps_mod
from app.automations.actions.agent_task.dependencies import (
DependencyError,
build_dependencies,
)
from app.automations.services.model_policy import AutomationModelPolicyError
pytestmark = pytest.mark.unit
class _FakeSession:
"""Minimal async session whose ``get`` returns a preset search space."""
def __init__(self, search_space: Any) -> None:
self._search_space = search_space
async def get(self, _model: Any, _pk: int) -> Any:
return self._search_space
@pytest.fixture
def patched_side_effects(monkeypatch: pytest.MonkeyPatch):
"""Stub the connector setup + checkpointer so only policy/LLM logic runs."""
async def _fake_setup(_session, *, search_space_id):
return (SimpleNamespace(name="connector"), "fc-key")
monkeypatch.setattr(deps_mod, "setup_connector_and_firecrawl", _fake_setup)
return None
async def test_build_dependencies_resolves_captured_agent_llm_id(
monkeypatch: pytest.MonkeyPatch, patched_side_effects
) -> None:
"""The bundle loads with the *captured* ``agent_llm_id``, not the live search space."""
captured: dict[str, Any] = {}
async def _fake_load(_session, *, config_id, search_space_id):
captured["config_id"] = config_id
captured["search_space_id"] = search_space_id
return (SimpleNamespace(name="llm"), SimpleNamespace(name="agent_config"), None)
monkeypatch.setattr(deps_mod, "load_llm_bundle", _fake_load)
# Captured path validates the explicit ids; passes for this test.
monkeypatch.setattr(deps_mod, "assert_models_billable", lambda **_kw: None)
# A different value on the live search space proves we ignore it when a
# snapshot is supplied.
monkeypatch.setattr(
deps_mod,
"assert_automation_models_billable",
lambda _ss: pytest.fail("search-space policy should not run on captured path"),
)
search_space = SimpleNamespace(agent_llm_id=-99)
result = await build_dependencies(
session=_FakeSession(search_space),
search_space_id=42,
agent_llm_id=-7,
image_generation_config_id=5,
vision_llm_config_id=-1,
)
assert captured == {"config_id": -7, "search_space_id": 42}
assert result.llm.name == "llm"
assert result.firecrawl_api_key == "fc-key"
async def test_build_dependencies_validates_captured_ids(
monkeypatch: pytest.MonkeyPatch, patched_side_effects
) -> None:
"""The captured ids (not the search space) are what gets policy-checked."""
seen: dict[str, Any] = {}
def _capture(**kwargs):
seen.update(kwargs)
monkeypatch.setattr(deps_mod, "assert_models_billable", _capture)
async def _fake_load(_session, *, config_id, search_space_id):
return (SimpleNamespace(name="llm"), SimpleNamespace(name="agent_config"), None)
monkeypatch.setattr(deps_mod, "load_llm_bundle", _fake_load)
await build_dependencies(
session=_FakeSession(SimpleNamespace(agent_llm_id=0)),
search_space_id=42,
agent_llm_id=-7,
image_generation_config_id=5,
vision_llm_config_id=-1,
)
assert seen == {
"agent_llm_id": -7,
"image_generation_config_id": 5,
"vision_llm_config_id": -1,
}
async def test_build_dependencies_raises_on_captured_policy_violation(
monkeypatch: pytest.MonkeyPatch, patched_side_effects
) -> None:
"""A blocked captured model raises ``DependencyError`` so the step fails clearly."""
def _raise(**_kw):
raise AutomationModelPolicyError(
[{"kind": "image", "config_id": -2, "reason": "free model"}]
)
monkeypatch.setattr(deps_mod, "assert_models_billable", _raise)
monkeypatch.setattr(
deps_mod,
"load_llm_bundle",
lambda *a, **k: pytest.fail("load_llm_bundle should not be called"),
)
with pytest.raises(DependencyError):
await build_dependencies(
session=_FakeSession(SimpleNamespace(agent_llm_id=-7)),
search_space_id=42,
agent_llm_id=-7,
image_generation_config_id=-2,
vision_llm_config_id=-1,
)
async def test_build_dependencies_falls_back_to_search_space(
monkeypatch: pytest.MonkeyPatch, patched_side_effects
) -> None:
"""With no captured snapshot, resolve + validate the live search space."""
captured: dict[str, Any] = {}
async def _fake_load(_session, *, config_id, search_space_id):
captured["config_id"] = config_id
return (SimpleNamespace(name="llm"), SimpleNamespace(name="agent_config"), None)
monkeypatch.setattr(deps_mod, "load_llm_bundle", _fake_load)
monkeypatch.setattr(deps_mod, "assert_automation_models_billable", lambda _ss: None)
monkeypatch.setattr(
deps_mod,
"assert_models_billable",
lambda **_kw: pytest.fail("captured policy should not run on fallback path"),
)
search_space = SimpleNamespace(agent_llm_id=-7)
result = await build_dependencies(
session=_FakeSession(search_space), search_space_id=42
)
assert captured == {"config_id": -7}
assert result.llm.name == "llm"
async def test_build_dependencies_raises_when_search_space_missing(
patched_side_effects,
) -> None:
"""A missing search space (fallback path) surfaces as a ``DependencyError``."""
with pytest.raises(DependencyError):
await build_dependencies(session=_FakeSession(None), search_space_id=999)

View file

@ -0,0 +1,59 @@
"""Lock that the executor propagates the captured model snapshot into the
``ActionContext``, so runs resolve their own model (insulated from chat /
search-space changes) and not the live search space.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import cast
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.automations.runtime.executor import _build_action_ctx
from app.automations.schemas.definition.envelope import AutomationModels
from app.automations.schemas.definition.plan_step import PlanStep
pytestmark = pytest.mark.unit
def _run() -> SimpleNamespace:
return SimpleNamespace(
id=1,
automation=SimpleNamespace(search_space_id=42, created_by_user_id="u-1"),
)
def test_build_action_ctx_propagates_captured_models() -> None:
"""``definition.models`` flows onto the ActionContext model fields."""
models = AutomationModels(
agent_llm_id=-1,
image_generation_config_id=5,
vision_llm_config_id=-1,
)
ctx = _build_action_ctx(
cast(AsyncSession, None),
_run(),
PlanStep(step_id="s1", action="agent_task"),
models,
)
assert ctx.search_space_id == 42
assert ctx.agent_llm_id == -1
assert ctx.image_generation_config_id == 5
assert ctx.vision_llm_config_id == -1
def test_build_action_ctx_none_models_leaves_fields_none() -> None:
"""No captured snapshot → model fields are None (defensive fallback path)."""
ctx = _build_action_ctx(
cast(AsyncSession, None),
_run(),
PlanStep(step_id="s1", action="agent_task"),
None,
)
assert ctx.agent_llm_id is None
assert ctx.image_generation_config_id is None
assert ctx.vision_llm_config_id is None

View file

@ -5,7 +5,10 @@ from __future__ import annotations
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
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.schemas.definition.plan_step import PlanStep
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
@ -27,6 +30,34 @@ def test_automation_definition_accepts_minimal_valid_input_with_sensible_default
assert definition.goal is None assert definition.goal is None
assert definition.inputs is None assert definition.inputs is None
assert definition.triggers == [] assert definition.triggers == []
# ``models`` is optional (populated server-side at create()).
assert definition.models is None
def test_automation_definition_models_round_trip() -> None:
"""The captured ``models`` snapshot survives a model_dump/validate round-trip."""
definition = AutomationDefinition(
name="Daily digest",
plan=[PlanStep(step_id="s1", action="agent_task")],
models=AutomationModels(
agent_llm_id=-1,
image_generation_config_id=5,
vision_llm_config_id=-1,
),
)
dumped = definition.model_dump(mode="json", by_alias=True)
assert dumped["models"] == {
"agent_llm_id": -1,
"image_generation_config_id": 5,
"vision_llm_config_id": -1,
}
restored = AutomationDefinition.model_validate(dumped)
assert restored.models is not None
assert restored.models.agent_llm_id == -1
assert restored.models.image_generation_config_id == 5
assert restored.models.vision_llm_config_id == -1
def test_automation_definition_rejects_unknown_top_level_field() -> None: def test_automation_definition_rejects_unknown_top_level_field() -> None:

View file

@ -0,0 +1,236 @@
"""Lock creation-time model-policy enforcement in ``AutomationService``.
Creation (REST + manual builder) rejects search spaces whose models aren't
billable for automations with HTTP 422, mirroring the runtime backstop. These
tests isolate the new ``_assert_models_billable`` / ``model_eligibility`` paths
without touching the DB commit.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
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.plan_step import PlanStep
from app.automations.services.automation import AutomationService
from app.automations.services.model_policy import AutomationModelPolicyError
pytestmark = pytest.mark.unit
class _FakeSession:
def __init__(self, search_space: Any) -> None:
self._search_space = search_space
self.added: list[Any] = []
self.commits = 0
async def get(self, _model: Any, _pk: int) -> Any:
return self._search_space
def add(self, obj: Any) -> None:
self.added.append(obj)
async def commit(self) -> None:
self.commits += 1
def _service(search_space: Any) -> AutomationService:
return AutomationService(
session=_FakeSession(search_space), user=SimpleNamespace(id="u-1")
)
def _definition(**kwargs: Any) -> AutomationDefinition:
return AutomationDefinition(
name="A",
plan=[PlanStep(step_id="s1", action="agent_task")],
**kwargs,
)
async def test_assert_models_billable_raises_422_on_violation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A blocked model maps the policy error to HTTP 422."""
def _raise(_ss):
raise AutomationModelPolicyError(
[{"kind": "llm", "config_id": 0, "reason": "Auto mode"}]
)
monkeypatch.setattr(automation_mod, "assert_automation_models_billable", _raise)
service = _service(SimpleNamespace(agent_llm_id=0))
with pytest.raises(HTTPException) as exc_info:
await service._assert_models_billable(1)
assert exc_info.value.status_code == 422
async def test_assert_models_billable_raises_404_when_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A missing search space is a 404, not a policy error."""
monkeypatch.setattr(
automation_mod, "assert_automation_models_billable", lambda _ss: None
)
service = _service(None)
with pytest.raises(HTTPException) as exc_info:
await service._assert_models_billable(999)
assert exc_info.value.status_code == 404
async def test_assert_models_billable_returns_search_space_when_ok(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When the policy accepts, the loaded search space is returned for reuse."""
monkeypatch.setattr(
automation_mod, "assert_automation_models_billable", lambda _ss: None
)
search_space = SimpleNamespace(agent_llm_id=-1)
service = _service(search_space)
assert await service._assert_models_billable(1) is search_space
async def test_create_injects_captured_models_from_search_space(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""create() snapshots the search space's model prefs onto the definition."""
monkeypatch.setattr(
automation_mod, "assert_automation_models_billable", lambda _ss: None
)
async def _noop_authorize(self, *_a, **_k):
return None
monkeypatch.setattr(AutomationService, "_authorize", _noop_authorize)
async def _return_added(self, _aid):
return self.session.added[-1]
monkeypatch.setattr(AutomationService, "_get_with_triggers_or_raise", _return_added)
search_space = SimpleNamespace(
agent_llm_id=-1,
image_generation_config_id=5,
vision_llm_config_id=-1,
)
service = _service(search_space)
payload = AutomationCreate(
search_space_id=1,
name="A",
definition=_definition(),
)
automation = await service.create(payload)
assert automation.definition["models"] == {
"agent_llm_id": -1,
"image_generation_config_id": 5,
"vision_llm_config_id": -1,
}
async def test_create_treats_unset_prefs_as_auto_zero(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``None`` search-space prefs are captured as ``0`` (Auto) ids."""
monkeypatch.setattr(
automation_mod, "assert_automation_models_billable", lambda _ss: None
)
async def _noop_authorize(self, *_a, **_k):
return None
monkeypatch.setattr(AutomationService, "_authorize", _noop_authorize)
async def _return_added(self, _aid):
return self.session.added[-1]
monkeypatch.setattr(AutomationService, "_get_with_triggers_or_raise", _return_added)
search_space = SimpleNamespace(
agent_llm_id=None,
image_generation_config_id=None,
vision_llm_config_id=None,
)
service = _service(search_space)
payload = AutomationCreate(search_space_id=1, name="A", definition=_definition())
automation = await service.create(payload)
assert automation.definition["models"] == {
"agent_llm_id": 0,
"image_generation_config_id": 0,
"vision_llm_config_id": 0,
}
async def test_update_preserves_captured_models(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A definition edit carries over the previously captured ``models``."""
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,
)
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())
# The incoming patch definition has no ``models`` (frontend strips it).
patch = AutomationUpdate(definition=_definition())
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:
"""``model_eligibility`` checks read access then returns the eligibility dict."""
authorized: dict[str, Any] = {}
async def _fake_check_permission(_session, _user, ss_id, permission, _msg):
authorized["search_space_id"] = ss_id
authorized["permission"] = permission
monkeypatch.setattr(automation_mod, "check_permission", _fake_check_permission)
monkeypatch.setattr(
automation_mod,
"get_automation_model_eligibility",
lambda _ss: {"allowed": False, "violations": [{"kind": "image"}]},
)
service = _service(SimpleNamespace(agent_llm_id=-2))
result = await service.model_eligibility(search_space_id=5)
assert result == {"allowed": False, "violations": [{"kind": "image"}]}
assert authorized["search_space_id"] == 5
assert authorized["permission"] == "automations:read"

View file

@ -0,0 +1,196 @@
"""Lock the automation model-billing policy.
Automations may only run on billable models: premium global configs
(``billing_tier == "premium"``) or user BYOK configs (positive id). Free
globals and Auto mode (id == 0 / None) are blocked. These tests pin that rule
across all three model slots (chat LLM, image, vision).
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import app.automations.services.model_policy as model_policy
from app.automations.services.model_policy import (
AutomationModelPolicyError,
assert_automation_models_billable,
assert_models_billable,
get_automation_model_eligibility,
get_model_eligibility,
)
pytestmark = pytest.mark.unit
def _search_space(*, llm: int | None, image: int | None, vision: int | None):
"""Minimal stand-in for the ``SearchSpace`` ORM row the policy reads."""
return SimpleNamespace(
agent_llm_id=llm,
image_generation_config_id=image,
vision_llm_config_id=vision,
)
@pytest.fixture
def patched_globals(monkeypatch: pytest.MonkeyPatch):
"""Stub the global config sources the policy consults for negative ids.
Negative ids: -1 is premium, -2 is free, for each of llm/image/vision.
"""
llm_configs = {
-1: {"id": -1, "billing_tier": "premium"},
-2: {"id": -2, "billing_tier": "free"},
}
monkeypatch.setattr(
"app.agents.new_chat.llm_config.load_global_llm_config_by_id",
lambda cid: llm_configs.get(cid),
)
from app.config import config as app_config
monkeypatch.setattr(
app_config,
"GLOBAL_IMAGE_GEN_CONFIGS",
[
{"id": -1, "billing_tier": "premium"},
{"id": -2, "billing_tier": "free"},
],
raising=False,
)
monkeypatch.setattr(
app_config,
"GLOBAL_VISION_LLM_CONFIGS",
[
{"id": -1, "billing_tier": "premium"},
{"id": -2, "billing_tier": "free"},
],
raising=False,
)
return None
@pytest.mark.parametrize("kind", ["llm", "image", "vision"])
def test_byok_positive_id_is_allowed(kind: str, patched_globals) -> None:
"""A positive config id is a user-owned BYOK model — always billable."""
allowed, reason = model_policy._classify(kind, 7)
assert allowed is True
assert reason == ""
@pytest.mark.parametrize("kind", ["llm", "image", "vision"])
@pytest.mark.parametrize("config_id", [0, None])
def test_auto_mode_is_blocked(kind: str, config_id, patched_globals) -> None:
"""Auto mode (id 0) and an unset slot (None) are blocked."""
allowed, reason = model_policy._classify(kind, config_id)
assert allowed is False
assert "Auto mode" in reason
@pytest.mark.parametrize("kind", ["llm", "image", "vision"])
def test_premium_global_is_allowed(kind: str, patched_globals) -> None:
"""A negative (global) id with premium billing tier is allowed."""
allowed, reason = model_policy._classify(kind, -1)
assert allowed is True
assert reason == ""
@pytest.mark.parametrize("kind", ["llm", "image", "vision"])
def test_free_global_is_blocked(kind: str, patched_globals) -> None:
"""A negative (global) id with a free billing tier is blocked."""
allowed, reason = model_policy._classify(kind, -2)
assert allowed is False
assert "free model" in reason
@pytest.mark.parametrize("kind", ["llm", "image", "vision"])
def test_unknown_global_id_is_blocked(kind: str, patched_globals) -> None:
"""A negative id that resolves to no config is treated as not premium."""
allowed, _ = model_policy._classify(kind, -999)
assert allowed is False
def test_eligibility_all_billable(patched_globals) -> None:
"""Premium LLM + BYOK image + premium vision → allowed, no violations."""
search_space = _search_space(llm=-1, image=5, vision=-1)
result = get_automation_model_eligibility(search_space)
assert result == {"allowed": True, "violations": []}
def test_eligibility_reports_each_violation(patched_globals) -> None:
"""A free LLM, Auto image, and free vision each produce a violation."""
search_space = _search_space(llm=-2, image=0, vision=-2)
result = get_automation_model_eligibility(search_space)
assert result["allowed"] is False
kinds = {v["kind"] for v in result["violations"]}
assert kinds == {"llm", "image", "vision"}
# config_id is echoed back for the UI / settings deep-link.
by_kind = {v["kind"]: v["config_id"] for v in result["violations"]}
assert by_kind == {"llm": -2, "image": 0, "vision": -2}
def test_assert_raises_with_violations(patched_globals) -> None:
"""``assert_automation_models_billable`` raises when any slot is blocked."""
search_space = _search_space(llm=0, image=5, vision=-1)
with pytest.raises(AutomationModelPolicyError) as exc_info:
assert_automation_models_billable(search_space)
assert len(exc_info.value.violations) == 1
assert exc_info.value.violations[0]["kind"] == "llm"
def test_assert_passes_when_all_billable(patched_globals) -> None:
"""No exception when every slot is premium or BYOK."""
search_space = _search_space(llm=3, image=-1, vision=4)
assert assert_automation_models_billable(search_space) is None
# --- ID-based core (used by the runtime backstop against captured snapshots) ---
def test_get_model_eligibility_all_billable(patched_globals) -> None:
"""Premium LLM + BYOK image + premium vision (explicit ids) → allowed."""
result = get_model_eligibility(
agent_llm_id=-1, image_generation_config_id=5, vision_llm_config_id=-1
)
assert result == {"allowed": True, "violations": []}
def test_get_model_eligibility_reports_each_violation(patched_globals) -> None:
"""Free LLM, Auto image, free vision (explicit ids) each produce a violation."""
result = get_model_eligibility(
agent_llm_id=-2, image_generation_config_id=0, vision_llm_config_id=-2
)
assert result["allowed"] is False
by_kind = {v["kind"]: v["config_id"] for v in result["violations"]}
assert by_kind == {"llm": -2, "image": 0, "vision": -2}
def test_assert_models_billable_raises(patched_globals) -> None:
"""``assert_models_billable`` raises when any explicit id is blocked."""
with pytest.raises(AutomationModelPolicyError) as exc_info:
assert_models_billable(
agent_llm_id=0, image_generation_config_id=5, vision_llm_config_id=-1
)
assert len(exc_info.value.violations) == 1
assert exc_info.value.violations[0]["kind"] == "llm"
def test_assert_models_billable_passes(patched_globals) -> None:
"""No exception when every explicit id is premium or BYOK."""
assert (
assert_models_billable(
agent_llm_id=3, image_generation_config_id=-1, vision_llm_config_id=4
)
is None
)
def test_search_space_wrapper_delegates_to_core(patched_globals) -> None:
"""The search-space wrapper produces the same result as the ID core."""
search_space = _search_space(llm=-2, image=0, vision=-2)
assert get_automation_model_eligibility(search_space) == get_model_eligibility(
agent_llm_id=-2, image_generation_config_id=0, vision_llm_config_id=-2
)

View file

@ -1,6 +1,8 @@
"use client"; "use client";
import { ShieldAlert } from "lucide-react"; import { ShieldAlert } from "lucide-react";
import { useAutomationModelEligibility } from "@/hooks/use-automation-model-eligibility";
import { useAutomations } from "@/hooks/use-automations"; import { useAutomations } from "@/hooks/use-automations";
import { AutomationModelGateAlert } from "./components/automation-model-gate-alert";
import { AutomationsEmptyState } from "./components/automations-empty-state"; import { AutomationsEmptyState } from "./components/automations-empty-state";
import { AutomationsHeader } from "./components/automations-header"; import { AutomationsHeader } from "./components/automations-header";
import { AutomationsTable } from "./components/automations-table"; import { AutomationsTable } from "./components/automations-table";
@ -22,6 +24,18 @@ interface AutomationsContentProps {
export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) { export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
const { automations, total, loading, error } = useAutomations(); const { automations, total, loading, error } = useAutomations();
const perms = useAutomationPermissions(); 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) { if (perms.loading) {
// Permissions gate the entire page; defer everything until we know. // Permissions gate the entire page; defer everything until we know.
@ -77,7 +91,11 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
canCreate={perms.canCreate} canCreate={perms.canCreate}
showCreateCta={false} showCreateCta={false}
/> />
<AutomationsEmptyState searchSpaceId={searchSpaceId} canCreate={perms.canCreate} /> <AutomationsEmptyState
searchSpaceId={searchSpaceId}
canCreate={perms.canCreate}
modelViolations={modelViolations}
/>
</> </>
); );
} }
@ -89,7 +107,12 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
total={total} total={total}
loading={loading} loading={loading}
canCreate={perms.canCreate} canCreate={perms.canCreate}
createDisabled={createDisabled}
disabledReason={disabledReason}
/> />
{modelViolations.length > 0 && (
<AutomationModelGateAlert searchSpaceId={searchSpaceId} violations={modelViolations} />
)}
<AutomationsTable <AutomationsTable
automations={automations} automations={automations}
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}

View file

@ -0,0 +1,61 @@
"use client";
import { Lock } from "lucide-react";
import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import type { ModelEligibilityViolation } from "@/contracts/types/automation.types";
interface AutomationModelGateAlertProps {
searchSpaceId: number;
violations: ModelEligibilityViolation[];
className?: string;
}
// Model selection for automations is managed under the roles tab, so every
// blocked slot deep-links there (the label still names which slot to fix).
const SETTINGS_TAB = "roles";
const KIND_LABEL: Record<ModelEligibilityViolation["kind"], string> = {
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 (
<Alert variant="warning" className={className}>
<Lock aria-hidden />
<AlertTitle>Automations need a premium or your own model</AlertTitle>
<AlertDescription>
<p>
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.
</p>
<ul className="mt-1 list-inside list-disc">
{violations.map((violation) => (
<li key={violation.kind}>
<Link
href={`/dashboard/${searchSpaceId}/search-space-settings/${SETTINGS_TAB}`}
className="font-medium text-foreground underline underline-offset-2 hover:no-underline"
>
{KIND_LABEL[violation.kind]}
</Link>
<span className="text-muted-foreground"> {violation.reason}</span>
</li>
))}
</ul>
</AlertDescription>
</Alert>
);
}

View file

@ -2,10 +2,14 @@
import { MessageSquarePlus, SquarePen, Workflow } from "lucide-react"; import { MessageSquarePlus, SquarePen, Workflow } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { ModelEligibilityViolation } from "@/contracts/types/automation.types";
import { AutomationModelGateAlert } from "./automation-model-gate-alert";
interface AutomationsEmptyStateProps { interface AutomationsEmptyStateProps {
searchSpaceId: number; searchSpaceId: number;
canCreate: boolean; canCreate: boolean;
/** Model slots that block creation (free/Auto). Empty when eligible. */
modelViolations?: ModelEligibilityViolation[];
} }
/** /**
@ -14,7 +18,13 @@ interface AutomationsEmptyStateProps {
* "new automation" form. We surface the chat path explicitly so users * "new automation" form. We surface the chat path explicitly so users
* don't go hunting for an "add" button that doesn't exist. * don't go hunting for an "add" button that doesn't exist.
*/ */
export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsEmptyStateProps) { export function AutomationsEmptyState({
searchSpaceId,
canCreate,
modelViolations = [],
}: AutomationsEmptyStateProps) {
const modelsBlocked = modelViolations.length > 0;
return ( return (
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-12 text-center"> <div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground"> <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
@ -26,20 +36,26 @@ export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsE
SurfSense drafts the automation for your approval. SurfSense drafts the automation for your approval.
</p> </p>
{canCreate ? ( {canCreate ? (
<div className="mt-6 flex items-center justify-center gap-2 flex-wrap"> modelsBlocked ? (
<Button asChild> <div className="mt-6 mx-auto max-w-md text-left">
<Link href={`/dashboard/${searchSpaceId}/new-chat`}> <AutomationModelGateAlert searchSpaceId={searchSpaceId} violations={modelViolations} />
<MessageSquarePlus className="mr-2 h-4 w-4" /> </div>
Create via chat ) : (
</Link> <div className="mt-6 flex items-center justify-center gap-2 flex-wrap">
</Button> <Button asChild>
<Button asChild variant="outline"> <Link href={`/dashboard/${searchSpaceId}/new-chat`}>
<Link href={`/dashboard/${searchSpaceId}/automations/new`}> <MessageSquarePlus className="mr-2 h-4 w-4" />
<SquarePen className="mr-2 h-4 w-4" /> Create via chat
Create manually </Link>
</Link> </Button>
</Button> <Button asChild variant="outline">
</div> <Link href={`/dashboard/${searchSpaceId}/automations/new`}>
<SquarePen className="mr-2 h-4 w-4" />
Create manually
</Link>
</Button>
</div>
)
) : ( ) : (
<p className="mt-6 text-xs text-muted-foreground"> <p className="mt-6 text-xs text-muted-foreground">
You don't have permission to create automations in this search space. You don't have permission to create automations in this search space.

View file

@ -1,7 +1,9 @@
"use client"; "use client";
import { MessageSquarePlus, SquarePen } from "lucide-react"; import { MessageSquarePlus, SquarePen } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface AutomationsHeaderProps { interface AutomationsHeaderProps {
searchSpaceId: number; searchSpaceId: number;
@ -14,8 +16,18 @@ interface AutomationsHeaderProps {
* there to avoid a duplicate button. * there to avoid a duplicate button.
*/ */
showCreateCta?: boolean; 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 * 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 create_automation tool runs inside chat with a HITL approval card), so
@ -27,6 +39,8 @@ export function AutomationsHeader({
loading, loading,
canCreate, canCreate,
showCreateCta = true, showCreateCta = true,
createDisabled = false,
disabledReason,
}: AutomationsHeaderProps) { }: AutomationsHeaderProps) {
return ( return (
<div className="flex items-center justify-between gap-4 flex-wrap"> <div className="flex items-center justify-between gap-4 flex-wrap">
@ -40,20 +54,70 @@ export function AutomationsHeader({
</div> </div>
{canCreate && showCreateCta && ( {canCreate && showCreateCta && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button asChild size="sm" variant="outline"> {createDisabled ? (
<Link href={`/dashboard/${searchSpaceId}/automations/new`}> <>
<SquarePen className="mr-2 h-4 w-4" /> <DisabledCta
Create manually variant="outline"
</Link> icon={<SquarePen className="mr-2 h-4 w-4" />}
</Button> label="Create manually"
<Button asChild size="sm"> reason={disabledReason ?? DEFAULT_DISABLED_REASON}
<Link href={`/dashboard/${searchSpaceId}/new-chat`}> />
<MessageSquarePlus className="mr-2 h-4 w-4" /> <DisabledCta
Create via chat icon={<MessageSquarePlus className="mr-2 h-4 w-4" />}
</Link> label="Create via chat"
</Button> reason={disabledReason ?? DEFAULT_DISABLED_REASON}
/>
</>
) : (
<>
<Button asChild size="sm" variant="outline">
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>
<SquarePen className="mr-2 h-4 w-4" />
Create manually
</Link>
</Button>
<Button asChild size="sm">
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
<MessageSquarePlus className="mr-2 h-4 w-4" />
Create via chat
</Link>
</Button>
</>
)}
</div> </div>
)} )}
</div> </div>
); );
} }
function DisabledCta({
icon,
label,
reason,
variant,
}: {
icon: ReactNode;
label: string;
reason: string;
variant?: "outline";
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
{/* aria-disabled (not `disabled`) keeps the button focusable so the
tooltip is reachable by hover and keyboard; onClick is a no-op. */}
<Button
size="sm"
variant={variant}
aria-disabled
className="cursor-not-allowed opacity-50"
onClick={(event) => event.preventDefault()}
>
{icon}
{label}
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{reason}</TooltipContent>
</Tooltip>
);
}

View file

@ -15,6 +15,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { import {
type Automation, type Automation,
automationCreateRequest, automationCreateRequest,
@ -45,6 +46,12 @@ interface AutomationBuilderFormProps {
searchSpaceId: number; searchSpaceId: number;
/** Required in edit mode; seeds the form and trigger reconciliation. */ /** Required in edit mode; seeds the form and trigger reconciliation. */
automation?: Automation; automation?: Automation;
/**
* When set (create mode only), the search space's models aren't billable
* for automations. Submit is disabled with this reason as the tooltip; the
* orchestrator also renders the full gate alert above the form.
*/
submitDisabledReason?: string;
} }
type Mode = "form" | "json"; type Mode = "form" | "json";
@ -66,6 +73,7 @@ export function AutomationBuilderForm({
mode, mode,
searchSpaceId, searchSpaceId,
automation, automation,
submitDisabledReason,
}: AutomationBuilderFormProps) { }: AutomationBuilderFormProps) {
const router = useRouter(); const router = useRouter();
const { mutateAsync: createAutomation } = useAtomValue(createAutomationMutationAtom); const { mutateAsync: createAutomation } = useAtomValue(createAutomationMutationAtom);
@ -273,6 +281,8 @@ export function AutomationBuilderForm({
} }
const submitLabel = mode === "edit" ? "Save changes" : "Create automation"; const submitLabel = mode === "edit" ? "Save changes" : "Create automation";
// Only gate creation; editing an existing automation isn't blocked here.
const submitBlocked = mode === "create" && !!submitDisabledReason;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -390,15 +400,39 @@ export function AutomationBuilderForm({
<Button asChild type="button" variant="ghost" size="sm"> <Button asChild type="button" variant="ghost" size="sm">
<Link href={cancelHref}>Cancel</Link> <Link href={cancelHref}>Cancel</Link>
</Button> </Button>
<Button {submitBlocked ? (
type="button" <Tooltip>
size="sm" <TooltipTrigger asChild>
disabled={submitting} {/* aria-disabled keeps the button focusable so the tooltip is
onClick={() => (activeMode === "json" ? submitJson() : submitForm())} reachable by hover and keyboard; onClick is a no-op. */}
> <Button
{submitting ? <Spinner size="xs" className="mr-2" /> : <Save className="mr-2 h-4 w-4" />} type="button"
{submitLabel} size="sm"
</Button> aria-disabled
className="cursor-not-allowed opacity-50"
onClick={(event) => event.preventDefault()}
>
<Save className="mr-2 h-4 w-4" />
{submitLabel}
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{submitDisabledReason}</TooltipContent>
</Tooltip>
) : (
<Button
type="button"
size="sm"
disabled={submitting}
onClick={() => (activeMode === "json" ? submitJson() : submitForm())}
>
{submitting ? (
<Spinner size="xs" className="mr-2" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{submitLabel}
</Button>
)}
</div> </div>
</div> </div>
); );

View file

@ -1,5 +1,7 @@
"use client"; "use client";
import { ShieldAlert } from "lucide-react"; import { ShieldAlert } from "lucide-react";
import { useAutomationModelEligibility } from "@/hooks/use-automation-model-eligibility";
import { AutomationModelGateAlert } from "../components/automation-model-gate-alert";
import { AutomationBuilderForm } from "../components/builder/automation-builder-form"; import { AutomationBuilderForm } from "../components/builder/automation-builder-form";
import { useAutomationPermissions } from "../hooks/use-automation-permissions"; import { useAutomationPermissions } from "../hooks/use-automation-permissions";
import { AutomationNewHeader } from "./components/automation-new-header"; import { AutomationNewHeader } from "./components/automation-new-header";
@ -16,6 +18,9 @@ interface AutomationNewContentProps {
*/ */
export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProps) { export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProps) {
const perms = useAutomationPermissions(); const perms = useAutomationPermissions();
const { data: eligibility, isLoading: eligibilityLoading } = useAutomationModelEligibility(
perms.canCreate ? searchSpaceId : undefined
);
if (perms.loading) { if (perms.loading) {
return <div className="h-32 rounded-md border border-border/60 bg-muted/10 animate-pulse" />; return <div className="h-32 rounded-md border border-border/60 bg-muted/10 animate-pulse" />;
@ -33,10 +38,22 @@ export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProp
); );
} }
const modelViolations = eligibility?.violations ?? [];
const submitDisabledReason = eligibilityLoading
? "Checking model eligibility…"
: modelViolations[0]?.reason;
return ( return (
<> <>
<AutomationNewHeader searchSpaceId={searchSpaceId} /> <AutomationNewHeader searchSpaceId={searchSpaceId} />
<AutomationBuilderForm mode="create" searchSpaceId={searchSpaceId} /> {modelViolations.length > 0 && (
<AutomationModelGateAlert searchSpaceId={searchSpaceId} violations={modelViolations} />
)}
<AutomationBuilderForm
mode="create"
searchSpaceId={searchSpaceId}
submitDisabledReason={submitDisabledReason}
/>
</> </>
); );
} }

View file

@ -118,6 +118,12 @@ export const updateLLMPreferencesMutationAtom = atomWithMutation((get) => {
cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)), cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)),
(old: Record<string, unknown> | undefined) => ({ ...old, ...request.data }) (old: Record<string, unknown> | undefined) => ({ ...old, ...request.data })
); );
// Automation eligibility is derived from these model preferences
// (agent/image/vision). Invalidate it so the automations gate alert
// reflects the new selection without a manual refresh.
queryClient.invalidateQueries({
queryKey: cacheKeys.automations.modelEligibility(Number(searchSpaceId)),
});
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(error.message || "Failed to update LLM preferences"); toast.error(error.message || "Failed to update LLM preferences");

View file

@ -60,6 +60,15 @@ export const inputs = z.object({
}); });
export type Inputs = z.infer<typeof inputs>; export type Inputs = z.infer<typeof inputs>;
// Captured model snapshot (server-managed). Set at create time and preserved
// across edits so runs are insulated from later chat/search-space model changes.
export const automationModels = z.object({
agent_llm_id: z.number().int().default(0),
image_generation_config_id: z.number().int().default(0),
vision_llm_config_id: z.number().int().default(0),
});
export type AutomationModels = z.infer<typeof automationModels>;
export const automationDefinition = z.object({ export const automationDefinition = z.object({
schema_version: z.string().default("1.0"), schema_version: z.string().default("1.0"),
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
@ -69,6 +78,7 @@ export const automationDefinition = z.object({
plan: z.array(planStep).min(1), plan: z.array(planStep).min(1),
execution: execution.default(execution.parse({})), execution: execution.default(execution.parse({})),
metadata: metadata.default(metadata.parse({})), metadata: metadata.default(metadata.parse({})),
models: automationModels.nullable().optional(),
}); });
export type AutomationDefinition = z.infer<typeof automationDefinition>; export type AutomationDefinition = z.infer<typeof automationDefinition>;
@ -191,3 +201,23 @@ export const runListParams = z.object({
offset: z.number().int().min(0).default(0), offset: z.number().int().min(0).default(0),
}); });
export type RunListParams = z.infer<typeof runListParams>; export type RunListParams = z.infer<typeof runListParams>;
// =============================================================================
// Model eligibility — mirror app/automations/api/automation.py (ModelEligibility)
// =============================================================================
export const modelEligibilityKind = z.enum(["llm", "image", "vision"]);
export type ModelEligibilityKind = z.infer<typeof modelEligibilityKind>;
export const modelEligibilityViolation = z.object({
kind: modelEligibilityKind,
config_id: z.number().nullable(),
reason: z.string(),
});
export type ModelEligibilityViolation = z.infer<typeof modelEligibilityViolation>;
export const modelEligibility = z.object({
allowed: z.boolean(),
violations: z.array(modelEligibilityViolation),
});
export type ModelEligibility = z.infer<typeof modelEligibility>;

View file

@ -0,0 +1,25 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { ModelEligibility } from "@/contracts/types/automation.types";
import { automationsApiService } from "@/lib/apis/automations-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
/**
* Whether the search space's configured models are billable for automations.
*
* Automations may only run on premium global models or user-provided (BYOK)
* models; free global models and Auto mode are blocked so every run is metered
* in premium credits. Creation surfaces use this to gate their CTAs before the
* user invests effort drafting an automation that can't be saved.
*
* Keyed by search space id (not the jotai "current scope" atom) so it can be
* used on the create route as well as the list page.
*/
export function useAutomationModelEligibility(searchSpaceId: number | undefined) {
return useQuery<ModelEligibility, Error>({
queryKey: cacheKeys.automations.modelEligibility(searchSpaceId ?? 0),
queryFn: () => automationsApiService.getModelEligibility(searchSpaceId as number),
enabled: !!searchSpaceId,
staleTime: 60_000,
});
}

View file

@ -6,6 +6,7 @@ import {
automationCreateRequest, automationCreateRequest,
automationListResponse, automationListResponse,
automationUpdateRequest, automationUpdateRequest,
modelEligibility,
type RunListParams, type RunListParams,
run, run,
runListResponse, runListResponse,
@ -62,6 +63,13 @@ class AutomationsApiService {
return baseApiService.delete(`${BASE}/${automationId}`); return baseApiService.delete(`${BASE}/${automationId}`);
}; };
// Whether the search space's models are billable for automations (premium
// global or BYOK). Used to gate creation surfaces before submit.
getModelEligibility = async (searchSpaceId: number) => {
const qs = new URLSearchParams({ search_space_id: String(searchSpaceId) });
return baseApiService.get(`${BASE}/model-eligibility?${qs.toString()}`, modelEligibility);
};
// ---- Triggers (sub-resource) -------------------------------------------- // ---- Triggers (sub-resource) --------------------------------------------
addTrigger = async (automationId: number, request: TriggerCreateRequest) => { addTrigger = async (automationId: number, request: TriggerCreateRequest) => {

View file

@ -134,5 +134,7 @@ export const cacheKeys = {
["automations", "runs", automationId, limit, offset] as const, ["automations", "runs", automationId, limit, offset] as const,
run: (automationId: number, runId: number) => run: (automationId: number, runId: number) =>
["automations", "runs", automationId, runId] as const, ["automations", "runs", automationId, runId] as const,
modelEligibility: (searchSpaceId: number) =>
["automations", "model-eligibility", searchSpaceId] as const,
}, },
}; };