2222from app .automations .services .model_policy import (
2323 AutomationModelPolicyError ,
2424 assert_automation_models_billable ,
25+ assert_models_billable ,
2526 get_automation_model_eligibility ,
2627)
2728from app .automations .triggers import get_trigger
@@ -43,16 +44,23 @@ async def create(self, payload: AutomationCreate) -> Automation:
4344 await self ._authorize (
4445 payload .search_space_id , Permission .AUTOMATIONS_CREATE .value
4546 )
46- search_space = await self ._assert_models_billable (payload .search_space_id )
47-
48- # Snapshot the search space's current (already-validated) model prefs onto
49- # the definition so runs are insulated from later chat/search-space model
50- # changes. Captured ids are guaranteed billable by the check above.
51- payload .definition .models = AutomationModels (
52- agent_llm_id = search_space .agent_llm_id or 0 ,
53- image_generation_config_id = search_space .image_generation_config_id or 0 ,
54- vision_llm_config_id = search_space .vision_llm_config_id or 0 ,
55- )
47+
48+ # Capture the model profile onto the definition so runs are insulated
49+ # from later chat/search-space model changes. Two sources:
50+ # 1. Explicit per-automation selection in ``payload.definition.models``
51+ # (manual builder + chat approval card). Validate the chosen ids.
52+ # 2. Fallback (no selection): snapshot the search space's current prefs.
53+ # Either way the captured ids are guaranteed billable (premium/BYOK).
54+ selected_models = payload .definition .models
55+ if selected_models is not None :
56+ self ._assert_selected_models_billable (selected_models )
57+ else :
58+ search_space = await self ._assert_models_billable (payload .search_space_id )
59+ payload .definition .models = AutomationModels (
60+ agent_llm_id = search_space .agent_llm_id or 0 ,
61+ image_generation_config_id = search_space .image_generation_config_id or 0 ,
62+ vision_llm_config_id = search_space .vision_llm_config_id or 0 ,
63+ )
5664
5765 automation = Automation (
5866 search_space_id = payload .search_space_id ,
@@ -122,13 +130,22 @@ async def update(self, automation_id: int, patch: AutomationUpdate) -> Automatio
122130 automation .status = data ["status" ]
123131 if "definition" in data :
124132 new_def = patch .definition .model_dump (mode = "json" , by_alias = True )
125- # Preserve the captured model snapshot across edits so a definition
126- # change never silently re-binds the automation to the current chat
127- # model selection. Backend-managed; survives whether or not the
128- # client round-trips ``models``.
133+ # Model snapshot handling on edit:
134+ # * absent in the patch -> preserve the captured snapshot
135+ # (a non-model definition change never silently re-binds the
136+ # automation to the current chat/search-space selection).
137+ # * unchanged from the snapshot -> keep as-is, no re-validation
138+ # (so editing an automation whose captured model later drifted
139+ # out of premium isn't blocked by an unrelated name/schedule edit).
140+ # * genuinely changed -> validate the new selection (422 on a
141+ # non-billable pick), then accept it.
129142 existing_models = (automation .definition or {}).get ("models" )
130- if existing_models is not None :
131- new_def ["models" ] = existing_models
143+ provided_models = new_def .get ("models" )
144+ if provided_models is None :
145+ if existing_models is not None :
146+ new_def ["models" ] = existing_models
147+ elif provided_models != existing_models :
148+ self ._assert_selected_models_billable (patch .definition .models )
132149 automation .definition = new_def
133150 automation .version += 1
134151
@@ -199,6 +216,22 @@ async def _assert_models_billable(self, search_space_id: int) -> SearchSpace:
199216 raise HTTPException (status_code = 422 , detail = str (exc )) from exc
200217 return search_space
201218
219+ def _assert_selected_models_billable (self , models : AutomationModels ) -> None :
220+ """Reject creation when an explicitly selected model isn't billable.
221+
222+ Used when the client supplies ``definition.models`` (per-automation
223+ selection from the builder or chat approval card). Same policy as the
224+ search-space path: premium global or BYOK only, no free/Auto.
225+ """
226+ try :
227+ assert_models_billable (
228+ agent_llm_id = models .agent_llm_id ,
229+ image_generation_config_id = models .image_generation_config_id ,
230+ vision_llm_config_id = models .vision_llm_config_id ,
231+ )
232+ except AutomationModelPolicyError as exc :
233+ raise HTTPException (status_code = 422 , detail = str (exc )) from exc
234+
202235 async def _authorize (self , search_space_id : int , permission : str ) -> None :
203236 await check_permission (
204237 self .session ,
0 commit comments