@@ -205,21 +205,54 @@ impl NodeExecutor for GenerateExecutor {
205205 let mut options = ChatOptions :: new ( )
206206 . with_model ( model_id) ;
207207
208- // Apply template config
208+ // Apply template config with capability-aware masking
209+ let model_caps = config. model_registry . get_capabilities ( provider_id, model_id) ;
210+ let sampling_config = config. model_registry . get_sampling ( provider_id) ;
211+
209212 if let Some ( tpl) = template {
210- options = options
211- . with_temperature ( tpl. config . temperature )
212- . with_max_tokens ( tpl. config . max_tokens ) ;
213+ // Temperature: clamp to model/provider max
214+ let max_temp = config. model_registry . max_temperature ( provider_id, model_id) ;
215+ let temp = tpl. config . temperature . min ( max_temp) ;
216+ options = options. with_temperature ( temp) ;
217+ options = options. with_max_tokens ( tpl. config . max_tokens ) ;
213218
219+ // Top P: only if provider supports it
214220 if let Some ( top_p) = tpl. config . top_p {
215- options = options. with_top_p ( top_p) ;
221+ let supports = sampling_config. map ( |s| s. supports_top_p ) . unwrap_or ( true ) ;
222+ if supports {
223+ // Check if both temp and top_p can coexist
224+ let allow_both = sampling_config. map ( |s| s. allow_both_temp_and_top_p ) . unwrap_or ( true ) ;
225+ if allow_both || tpl. config . temperature == 0.0 {
226+ options = options. with_top_p ( top_p) ;
227+ } else {
228+ debug ! ( node = %node_id, "Skipping top_p: provider doesn't allow both temperature and top_p" ) ;
229+ }
230+ } else {
231+ debug ! ( node = %node_id, "Skipping top_p: provider doesn't support it" ) ;
232+ }
216233 }
234+
235+ // Top K: only if provider supports it
217236 if let Some ( top_k) = tpl. config . top_k {
218- options = options. with_top_k ( top_k) ;
237+ let supports = sampling_config. map ( |s| s. supports_top_k ) . unwrap_or ( false ) ;
238+ if supports {
239+ options = options. with_top_k ( top_k) ;
240+ } else {
241+ debug ! ( node = %node_id, "Skipping top_k: provider doesn't support it" ) ;
242+ }
219243 }
244+
245+ // Thinking budget: only if model supports thinking
220246 if let Some ( budget) = tpl. config . thinking_budget {
221- options = options. with_thinking_budget ( budget) ;
247+ let supports = model_caps. map ( |c| c. thinking != "none" ) . unwrap_or ( true ) ;
248+ if supports {
249+ options = options. with_thinking_budget ( budget) ;
250+ } else {
251+ debug ! ( node = %node_id, "Skipping thinking_budget: model doesn't support thinking" ) ;
252+ }
222253 }
254+
255+ // Reasoning effort
223256 if let Some ( ref effort) = tpl. config . reasoning_effort {
224257 let parsed = match effort. as_str ( ) {
225258 "low" => crate :: providers:: ReasoningEffort :: Low ,
@@ -228,29 +261,72 @@ impl NodeExecutor for GenerateExecutor {
228261 } ;
229262 options = options. with_reasoning_effort ( parsed) ;
230263 }
264+
231265 if tpl. config . enable_search {
232266 options = options. with_enable_search ( true ) ;
233267 }
234268 }
235269
236- // Enable JSON mode if we have output schema
237- if output_schema. map ( |s| !s. is_empty ( ) ) . unwrap_or ( false ) {
238- options = options. with_json_output ( ) ;
270+ // JSON output: only if model supports it, otherwise rely on prompt + robust extraction
271+ let has_schema = output_schema. map ( |s| !s. is_empty ( ) ) . unwrap_or ( false ) ;
272+ if has_schema {
273+ let supports_json = model_caps. map ( |c| c. json ) . unwrap_or ( true ) ;
274+ if supports_json {
275+ // Check thinking + JSON compatibility
276+ let has_thinking = options. thinking_budget . is_some ( ) ;
277+ if has_thinking {
278+ let compat = model_caps
279+ . map ( |c| & c. thinking_json_compat )
280+ . unwrap_or ( & crate :: providers:: model_registry:: ThinkingJsonCompat :: Full ) ;
281+ match compat {
282+ crate :: providers:: model_registry:: ThinkingJsonCompat :: None => {
283+ // Incompatible: prefer JSON, drop thinking
284+ debug ! ( node = %node_id, "Thinking+JSON incompatible: disabling thinking, keeping JSON" ) ;
285+ options. thinking_budget = None ;
286+ options = options. with_json_output ( ) ;
287+ }
288+ _ => {
289+ // Full or Partial: enable both
290+ options = options. with_json_output ( ) ;
291+ }
292+ }
293+ } else {
294+ options = options. with_json_output ( ) ;
295+ }
296+ } else {
297+ debug ! (
298+ node = %node_id,
299+ provider = %provider_id,
300+ model = %model_id,
301+ "Model doesn't support JSON output mode, relying on prompt injection + robust extraction"
302+ ) ;
303+ // Don't set json_output — prompt already has "Respond in JSON format: {...}"
304+ }
239305 }
240306
241- // Build tool definitions for native function calling
307+ // Tool definitions: only if model supports function calling
242308 if !node_config. tools . is_empty ( ) {
243- let tool_defs: Vec < crate :: providers:: ToolDefinition > = node_config. tools . iter ( )
244- . map ( |t| crate :: providers:: ToolDefinition {
245- name : t. name . clone ( ) ,
246- description : t. description . clone ( ) . unwrap_or_default ( ) ,
247- parameters : t. parameters . clone ( ) . unwrap_or ( serde_json:: json!( {
248- "type" : "object" ,
249- "properties" : { } ,
250- } ) ) ,
251- } )
252- . collect ( ) ;
253- options = options. with_tools ( tool_defs) ;
309+ let supports_fc = model_caps. map ( |c| c. function_calling ) . unwrap_or ( true ) ;
310+ if supports_fc {
311+ let tool_defs: Vec < crate :: providers:: ToolDefinition > = node_config. tools . iter ( )
312+ . map ( |t| crate :: providers:: ToolDefinition {
313+ name : t. name . clone ( ) ,
314+ description : t. description . clone ( ) . unwrap_or_default ( ) ,
315+ parameters : t. parameters . clone ( ) . unwrap_or ( serde_json:: json!( {
316+ "type" : "object" ,
317+ "properties" : { } ,
318+ } ) ) ,
319+ } )
320+ . collect ( ) ;
321+ options = options. with_tools ( tool_defs) ;
322+ } else {
323+ warn ! (
324+ node = %node_id,
325+ provider = %provider_id,
326+ model = %model_id,
327+ "Model doesn't support function calling, tools will not be available"
328+ ) ;
329+ }
254330 }
255331
256332 debug ! (
0 commit comments