Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit dc8daf3

Browse files
jieyefriicclaude
andcommitted
feat: capability-aware ChatOptions masking via ModelRegistry
Expand ModelRegistry from vision-only to full capability tracking: - Provider-level: maxTemperature, supportsTopP/TopK, allowBothTempAndTopP - Model-level: json, thinking, thinkingJsonCompat, functionCalling, vision, thinkingBudget (with budgetParamName), samplingOverride Generate node now masks ChatOptions based on capabilities: - json_output: only sent if model.json=true, otherwise rely on prompt injection + robust JSON extraction - top_p/top_k: only sent if provider supports them - temperature: clamped to provider/model maxTemperature - thinking_budget: only sent if model.thinking != "none" - thinking+JSON conflict: if thinkingJsonCompat=none, drops thinking - tools: only sent if model.functionCalling=true Providers that don't support JSON mode (e.g., some Doubao configs) now gracefully degrade to prompt-based JSON + extraction instead of failing with API errors. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent b518669 commit dc8daf3

2 files changed

Lines changed: 394 additions & 84 deletions

File tree

src/nodes/generate.rs

Lines changed: 98 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)