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

@ -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)