diff --git a/lib/crates/fabro-agent/src/config.rs b/lib/crates/fabro-agent/src/config.rs index 6e2f626b6..0eb0c39c4 100644 --- a/lib/crates/fabro-agent/src/config.rs +++ b/lib/crates/fabro-agent/src/config.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use fabro_llm::types::ReasoningEffort; +use fabro_llm::types::{ReasoningEffort, Speed}; use fabro_mcp::config::McpServerSettings; /// Callback invoked before each tool execution. Return `Ok(())` to allow, @@ -65,7 +65,7 @@ pub struct SessionOptions { pub default_command_timeout_ms: u64, pub max_command_timeout_ms: u64, pub reasoning_effort: Option, - pub speed: Option, + pub speed: Option, pub tool_output_limits: HashMap, pub tool_line_limits: HashMap, /// Override the provider's default max_tokens when set. diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index 9544c36ed..5798bae96 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -966,7 +966,7 @@ impl Session { self.config.reasoning_effort = effort; } - pub fn set_speed(&mut self, speed: Option) { + pub fn set_speed(&mut self, speed: Option) { self.config.speed = speed; } @@ -1344,11 +1344,6 @@ impl Session { }); // Emit AssistantMessage with enriched data from the response - let speed = self - .config - .speed - .as_deref() - .and_then(|value| value.parse::().ok()); let model = ModelRef { provider: self.provider_profile.provider_id(), model_id: if response.model.is_empty() { @@ -1356,7 +1351,7 @@ impl Session { } else { response.model.clone() }, - speed, + speed: self.config.speed, }; self.event_emitter .emit(self.id.clone(), AgentEvent::AssistantMessage { @@ -1565,7 +1560,7 @@ impl Session { }), stop_sequences: None, reasoning_effort: self.config.reasoning_effort, - speed: self.config.speed.clone(), + speed: self.config.speed, metadata: None, provider_options: None, } diff --git a/lib/crates/fabro-llm/src/client.rs b/lib/crates/fabro-llm/src/client.rs index 723a77753..871870664 100644 --- a/lib/crates/fabro-llm/src/client.rs +++ b/lib/crates/fabro-llm/src/client.rs @@ -10,7 +10,7 @@ use crate::adapter_registry::{AdapterConfig, factory_for}; use crate::error::Error; use crate::middleware::{Middleware, NextFn, NextStreamFn}; use crate::provider::{ProviderAdapter, StreamEventStream}; -use crate::types::{Request, Response}; +use crate::types::{Request, Response, Speed}; /// The core client that routes requests to provider adapters (Section 2.2, 3). #[derive(Clone)] @@ -199,6 +199,44 @@ impl Client { }) } + fn validate_request_controls(&self, request: &Request) -> Result<(), Error> { + let Some(catalog) = &self.catalog else { + return Ok(()); + }; + let Some(settings) = catalog.model_settings(&request.model) else { + return Ok(()); + }; + let model_id = catalog + .get(&request.model) + .map_or(request.model.as_str(), |model| model.id.as_str()); + + if let Some(effort) = request.reasoning_effort { + if !settings.controls.reasoning_effort.contains(&effort) { + return Err(Error::Configuration { + message: format!( + "model '{model_id}' does not support reasoning_effort '{effort}'; allowed values: {}", + format_control_values(&settings.controls.reasoning_effort), + ), + source: None, + }); + } + } + + if let Some(speed) = request.speed { + if speed != Speed::Standard && !settings.controls.speed.contains(&speed) { + return Err(Error::Configuration { + message: format!( + "model '{model_id}' does not support speed '{speed}'; allowed values: standard{}", + format_additional_speeds(&settings.controls.speed), + ), + source: None, + }); + } + } + + Ok(()) + } + /// Send a blocking request (Section 4.1). /// /// # Errors @@ -207,6 +245,7 @@ impl Client { /// registered, or any provider/middleware error encountered during the /// request. pub async fn complete(&self, request: &Request) -> Result { + self.validate_request_controls(request)?; let provider = self.resolve_provider(request)?; if self.middleware.is_empty() { @@ -240,6 +279,7 @@ impl Client { /// registered, or any provider/middleware error encountered during the /// request. pub async fn stream(&self, request: &Request) -> Result { + self.validate_request_controls(request)?; let provider = self.resolve_provider(request)?; if self.middleware.is_empty() { @@ -304,6 +344,26 @@ impl Client { } } +fn format_control_values(values: &[T]) -> String { + if values.is_empty() { + "none".to_string() + } else { + values + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + } +} + +fn format_additional_speeds(values: &[Speed]) -> String { + if values.is_empty() { + String::new() + } else { + format!(", {}", format_control_values(values)) + } +} + pub(crate) fn auth_value(auth_header: &ApiKeyHeader) -> String { match auth_header { ApiKeyHeader::Bearer(value) | ApiKeyHeader::Custom { value, .. } => value.clone(), @@ -483,6 +543,132 @@ mod tests { assert!(matches!(result.unwrap_err(), Error::Configuration { .. })); } + #[tokio::test] + async fn complete_rejects_unsupported_reasoning_effort_before_dispatch() { + let catalog = + Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()); + let mut client = Client::new(HashMap::new(), None, vec![]); + client.catalog = Some(Arc::clone(&catalog)); + client + .register_provider(Arc::new(MockProvider::new("kimi", "should not dispatch"))) + .await + .unwrap(); + + let mut request = test_request(); + request.model = "kimi-k2.5".to_string(); + request.provider = Some("kimi".to_string()); + request.reasoning_effort = Some(ReasoningEffort::High); + + let err = client.complete(&request).await.unwrap_err(); + + assert!(matches!( + err, + Error::Configuration { + ref message, + .. + } if message.contains("model 'kimi-k2.5' does not support reasoning_effort 'high'") + )); + } + + #[tokio::test] + async fn complete_rejects_unsupported_speed_before_dispatch() { + let catalog = + Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()); + let mut client = Client::new(HashMap::new(), None, vec![]); + client.catalog = Some(Arc::clone(&catalog)); + client + .register_provider(Arc::new(MockProvider::new("openai", "should not dispatch"))) + .await + .unwrap(); + + let mut request = test_request(); + request.model = "gpt-5.4".to_string(); + request.provider = Some("openai".to_string()); + request.speed = Some(Speed::Fast); + + let err = client.complete(&request).await.unwrap_err(); + + assert!(matches!( + err, + Error::Configuration { + ref message, + .. + } if message.contains("model 'gpt-5.4' does not support speed 'fast'") + )); + } + + #[tokio::test] + async fn complete_accepts_standard_speed_without_catalog_declaration() { + let catalog = + Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()); + let mut client = Client::new(HashMap::new(), None, vec![]); + client.catalog = Some(Arc::clone(&catalog)); + client + .register_provider(Arc::new(MockProvider::new("openai", "standard"))) + .await + .unwrap(); + + let mut request = test_request(); + request.model = "gpt-5.4".to_string(); + request.provider = Some("openai".to_string()); + request.speed = Some(Speed::Standard); + + let response = client.complete(&request).await.unwrap(); + + assert_eq!(response.text(), "standard"); + } + + #[tokio::test] + async fn complete_skips_control_validation_for_unknown_model_passthrough() { + let catalog = + Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()); + let mut client = Client::new(HashMap::new(), None, vec![]); + client.catalog = Some(Arc::clone(&catalog)); + client + .register_provider(Arc::new(MockProvider::new("openai", "passthrough"))) + .await + .unwrap(); + + let mut request = test_request(); + request.model = "custom-model".to_string(); + request.provider = Some("openai".to_string()); + request.reasoning_effort = Some(ReasoningEffort::High); + request.speed = Some(Speed::Fast); + + let response = client.complete(&request).await.unwrap(); + + assert_eq!(response.text(), "passthrough"); + } + + #[tokio::test] + async fn stream_rejects_unsupported_speed_before_dispatch() { + let catalog = + Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()); + let mut client = Client::new(HashMap::new(), None, vec![]); + client.catalog = Some(Arc::clone(&catalog)); + client + .register_provider(Arc::new(MockProvider::new("openai", "should not dispatch"))) + .await + .unwrap(); + + let mut request = test_request(); + request.model = "gpt-5.4".to_string(); + request.provider = Some("openai".to_string()); + request.speed = Some(Speed::Fast); + + let Err(err) = client.stream(&request).await else { + panic!("unsupported speed should fail before stream dispatch"); + }; + + assert!(matches!( + err, + Error::Configuration { + ref message, + .. + } if message.contains("model 'gpt-5.4' does not support speed 'fast'") + )); + } + #[tokio::test] async fn from_credentials_registers_multiple_providers() { let client = Client::from_credentials(vec![ diff --git a/lib/crates/fabro-llm/src/generate.rs b/lib/crates/fabro-llm/src/generate.rs index df0106162..6847e3d4b 100644 --- a/lib/crates/fabro-llm/src/generate.rs +++ b/lib/crates/fabro-llm/src/generate.rs @@ -17,8 +17,8 @@ use crate::retry::retry; use crate::tools::{RepairToolCallFn, Tool, execute_all_tools_with_repair}; use crate::types::{ FinishReason, GenerateResult, Message, ObjectStreamEvent, ReasoningEffort, Request, Response, - ResponseFormat, ResponseFormatType, RetryPolicy, StepResult, StreamEvent, TimeoutOptions, - TokenCounts, ToolCall, ToolChoice, ToolDefinition, + ResponseFormat, ResponseFormatType, RetryPolicy, Speed, StepResult, StreamEvent, + TimeoutOptions, TokenCounts, ToolCall, ToolChoice, ToolDefinition, }; fn build_initial_messages(params: &GenerateParams) -> Result, Error> { @@ -57,7 +57,7 @@ fn build_request( max_tokens: params.max_tokens, stop_sequences: params.stop_sequences.clone(), reasoning_effort: params.reasoning_effort, - speed: params.speed.clone(), + speed: params.speed, metadata: params.metadata.clone(), provider_options: params.provider_options.clone(), } @@ -280,7 +280,7 @@ pub struct GenerateParams { pub max_tokens: Option, pub stop_sequences: Option>, pub reasoning_effort: Option, - pub speed: Option, + pub speed: Option, pub provider: Option, pub provider_options: Option, pub metadata: Option>, @@ -402,6 +402,12 @@ impl GenerateParams { self } + #[must_use] + pub const fn speed(mut self, speed: Speed) -> Self { + self.speed = Some(speed); + self + } + #[must_use] pub fn provider_options(mut self, provider_options: serde_json::Value) -> Self { self.provider_options = Some(provider_options); @@ -1480,6 +1486,7 @@ mod tests { .max_tokens(100) .stop_sequences(vec!["STOP".to_string()]) .reasoning_effort(ReasoningEffort::High) + .speed(Speed::Fast) .provider("anthropic") .provider_options(serde_json::json!({"key": "value"})) .max_retries(5) @@ -1499,6 +1506,7 @@ mod tests { assert_eq!(params.max_tokens, Some(100)); assert_eq!(params.stop_sequences, Some(vec!["STOP".to_string()])); assert_eq!(params.reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!(params.speed, Some(Speed::Fast)); assert_eq!(params.provider.as_deref(), Some("anthropic")); assert!(params.provider_options.is_some()); assert_eq!(params.max_retries, 5); diff --git a/lib/crates/fabro-llm/src/providers/anthropic.rs b/lib/crates/fabro-llm/src/providers/anthropic.rs index 93c48d28a..6cb8b5a0c 100644 --- a/lib/crates/fabro-llm/src/providers/anthropic.rs +++ b/lib/crates/fabro-llm/src/providers/anthropic.rs @@ -13,7 +13,7 @@ use crate::providers::common::{ }; use crate::types::{ AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, ReasoningEffort, Request, - Response, ResponseFormatType, Role, StreamEvent, ThinkingData, TokenCounts, ToolCall, + Response, ResponseFormatType, Role, Speed, StreamEvent, ThinkingData, TokenCounts, ToolCall, ToolChoice, ToolDefinition, }; @@ -1209,7 +1209,7 @@ async fn build_api_request( output_config = None; } - let is_fast = request.speed.as_deref() == Some("fast"); + let is_fast = request.speed == Some(Speed::Fast); let api_request = ApiRequest { model: common::api_model_id(adapter.catalog.as_deref(), &request.model), @@ -1223,7 +1223,11 @@ async fn build_api_request( tool_choice: tool_choice_json, thinking, output_config, - speed: request.speed.clone(), + speed: request + .speed + .filter(|speed| *speed != Speed::Standard) + .map(<&'static str>::from) + .map(str::to_string), metadata: request.metadata.clone(), stream, }; @@ -2412,7 +2416,7 @@ mod tests { async fn build_api_request_sets_speed() { let adapter = Adapter::new("test-key"); let request = Request { - speed: Some("fast".to_string()), + speed: Some(Speed::Fast), ..make_base_request() }; @@ -2424,7 +2428,7 @@ mod tests { async fn build_api_request_injects_fast_mode_beta_header() { let adapter = Adapter::new("test-key"); let request = Request { - speed: Some("fast".to_string()), + speed: Some(Speed::Fast), ..make_base_request() }; diff --git a/lib/crates/fabro-llm/src/types.rs b/lib/crates/fabro-llm/src/types.rs index 80f46cf83..a5a14b359 100644 --- a/lib/crates/fabro-llm/src/types.rs +++ b/lib/crates/fabro-llm/src/types.rs @@ -368,7 +368,7 @@ impl<'de> Deserialize<'de> for FinishReason { // --- 3.9 TokenCounts --- -pub use fabro_model::TokenCounts; +pub use fabro_model::{Speed, TokenCounts}; // --- 3.10 ResponseFormat --- @@ -430,7 +430,7 @@ pub struct Request { pub max_tokens: Option, pub stop_sequences: Option>, pub reasoning_effort: Option, - pub speed: Option, + pub speed: Option, pub metadata: Option>, pub provider_options: Option, } diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index 73040eaa2..77a903d92 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -9,12 +9,13 @@ use fabro_agent::{ ToolEnvProvider, Turn, }; use fabro_auth::{CredentialSource, EnvCredentialSource}; -use fabro_graphviz::graph::Node; +use fabro_graphviz::graph::{AttrValue, Node}; use fabro_llm::client::Client; -use fabro_llm::types::{Message, Request, TokenCounts}; +use fabro_llm::types::{Message, ReasoningEffort, Request, Speed, TokenCounts}; use fabro_mcp::config::McpServerSettings; use fabro_model::catalog::LlmCatalogSettings; use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, Provider, ProviderId, adapter}; +use fabro_types::settings::run::RunModelControls; use fabro_types::{SessionCapability, StageId}; use tokio::sync::Mutex as TokioMutex; use tokio::task::JoinHandle; @@ -106,6 +107,12 @@ struct ProviderContext { profile_kind: AgentProfileKind, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct EffectiveRequestControls { + reasoning_effort: Option, + speed: Option, +} + fn classify_agent_error(err: fabro_agent::Error, allow_failover: bool) -> AgentApiErrorDisposition { match err { fabro_agent::Error::Interrupted(fabro_agent::InterruptReason::Cancelled) => { @@ -196,6 +203,72 @@ fn default_profile_kind(provider: Provider) -> AgentProfileKind { } } +fn effective_request_controls( + catalog: &Catalog, + run_model_controls: &RunModelControls, + model: &str, + node: &Node, +) -> Result { + let reasoning_effort = match control_attr(node, "reasoning_effort") + .or(run_model_controls.reasoning_effort.as_deref()) + { + Some(value) => Some(parse_reasoning_effort(node, value)?), + None => legacy_reasoning_effort_default(catalog, model), + }; + let speed = control_attr(node, "speed") + .or(run_model_controls.speed.as_deref()) + .map(|value| parse_speed(node, value)) + .transpose()?; + + Ok(EffectiveRequestControls { + reasoning_effort, + speed, + }) +} + +fn control_attr<'a>(node: &'a Node, key: &str) -> Option<&'a str> { + node.attrs.get(key).and_then(AttrValue::as_str) +} + +fn parse_reasoning_effort(node: &Node, value: &str) -> Result { + value.parse::().map_err(|source| { + Error::handler_with_source( + format!( + "Invalid reasoning_effort \"{value}\" for node \"{}\"; expected one of: low, medium, high, xhigh, max", + node.id + ), + &source, + ) + }) +} + +fn parse_speed(node: &Node, value: &str) -> Result { + value.parse::().map_err(|source| { + Error::handler_with_source( + format!( + "Invalid speed \"{value}\" for node \"{}\"; expected one of: standard, fast", + node.id + ), + &source, + ) + }) +} + +fn legacy_reasoning_effort_default(catalog: &Catalog, model: &str) -> Option { + match catalog.model_settings(model) { + Some(settings) + if settings + .controls + .reasoning_effort + .contains(&ReasoningEffort::High) => + { + Some(ReasoningEffort::High) + } + Some(_) => None, + None => Some(ReasoningEffort::High), + } +} + fn profile_provider_for_catalog_provider( provider_id: &ProviderId, profile_kind: AgentProfileKind, @@ -293,17 +366,18 @@ fn spawn_event_forwarder( /// For `full` fidelity nodes sharing a thread key, sessions are cached /// and reused so the LLM sees the full conversation history. pub struct AgentApiBackend { - model: String, - provider: Provider, - provider_id: ProviderId, - profile_kind: AgentProfileKind, - fallback_chain: Vec, - sessions: Mutex>, - tool_env: Option>, - mcp_servers: Vec, - source: Arc, - steering_hub: Arc, - catalog: Arc, + model: String, + provider: Provider, + provider_id: ProviderId, + profile_kind: AgentProfileKind, + fallback_chain: Vec, + sessions: Mutex>, + tool_env: Option>, + mcp_servers: Vec, + run_model_controls: RunModelControls, + source: Arc, + steering_hub: Arc, + catalog: Arc, } impl AgentApiBackend { @@ -351,6 +425,7 @@ impl AgentApiBackend { sessions: Mutex::new(HashMap::new()), tool_env: None, mcp_servers: Vec::new(), + run_model_controls: RunModelControls::default(), source, steering_hub, catalog, @@ -391,6 +466,20 @@ impl AgentApiBackend { self } + #[must_use] + pub fn with_run_model_controls(mut self, controls: RunModelControls) -> Self { + self.run_model_controls = controls; + self + } + + fn effective_request_controls( + &self, + model: &str, + node: &Node, + ) -> Result { + effective_request_controls(self.catalog.as_ref(), &self.run_model_controls, model, node) + } + fn resolve_provider_context( &self, model: &str, @@ -451,6 +540,7 @@ impl AgentApiBackend { sandbox, self.source.as_ref(), Arc::clone(&self.catalog), + &self.run_model_controls, self.tool_env.as_ref(), tool_hooks, self.mcp_servers.clone(), @@ -465,11 +555,14 @@ impl AgentApiBackend { sandbox: &Arc, source: &dyn CredentialSource, catalog: Arc, + run_model_controls: &RunModelControls, tool_env: Option<&Arc>, tool_hooks: Option>, mcp_servers: Vec, ) -> Result { - let client = Client::from_source_with_catalog(source, catalog) + let controls = + effective_request_controls(catalog.as_ref(), run_model_controls, model, node)?; + let client = Client::from_source_with_catalog(source, Arc::clone(&catalog)) .await .map_err(|e| Error::handler_with_source("Failed to create LLM client", &e))?; @@ -482,8 +575,8 @@ impl AgentApiBackend { let config = SessionOptions { max_tokens: node.max_tokens(), - reasoning_effort: node.reasoning_effort().parse().ok(), - speed: node.speed().map(String::from), + reasoning_effort: controls.reasoning_effort, + speed: controls.speed, tool_hooks, mcp_servers, ..SessionOptions::default() @@ -511,7 +604,11 @@ impl AgentApiBackend { factory_client.clone(), child_profile, Arc::clone(&factory_env), - SessionOptions::default(), + SessionOptions { + reasoning_effort: controls.reasoning_effort, + speed: controls.speed, + ..SessionOptions::default() + }, None, ); if let Some(provider) = &factory_tool_env { @@ -614,6 +711,7 @@ impl CodergenBackend for AgentApiBackend { let model = node.model().unwrap_or(&self.model); let provider = self.resolve_provider_context(model, node.provider())?; let provider_id = provider.provider_id.to_string(); + let controls = self.effective_request_controls(model, node)?; let max_tokens = node .max_tokens() @@ -629,8 +727,8 @@ impl CodergenBackend for AgentApiBackend { model: model.to_string(), messages, provider: Some(provider_id), - reasoning_effort: node.reasoning_effort().parse().ok(), - speed: node.speed().map(String::from), + reasoning_effort: controls.reasoning_effort, + speed: controls.speed, tools: None, tool_choice: None, response_format: None, @@ -692,11 +790,14 @@ impl CodergenBackend for AgentApiBackend { .get(&target.model) .and_then(|m| m.limits.max_output) }); + let fallback_controls = self.effective_request_controls(&target.model, node)?; let fallback_request = Request { model: target.model.clone(), provider: Some(target.provider.clone()), max_tokens, + reasoning_effort: fallback_controls.reasoning_effort, + speed: fallback_controls.speed, ..request.clone() }; @@ -920,6 +1021,7 @@ impl CodergenBackend for AgentApiBackend { sandbox, self.source.as_ref(), Arc::clone(&self.catalog), + &self.run_model_controls, self.tool_env.as_ref(), tool_hooks.clone(), self.mcp_servers.clone(), @@ -1386,6 +1488,75 @@ effort = false assert_eq!(provider.provider, Provider::OpenAiCompatible); } + #[test] + fn run_model_controls_apply_when_node_omits_controls() { + let backend = AgentApiBackend::new_from_env( + "gpt-5.4".to_string(), + Provider::OpenAi, + Vec::new(), + SteeringHub::for_tests(), + ) + .with_run_model_controls(fabro_types::settings::run::RunModelControls { + reasoning_effort: Some("low".to_string()), + speed: Some("fast".to_string()), + }); + let node = Node::new("work"); + + let controls = backend + .effective_request_controls("gpt-5.4", &node) + .unwrap(); + + assert_eq!(controls.reasoning_effort, Some(ReasoningEffort::Low)); + assert_eq!(controls.speed, Some(Speed::Fast)); + } + + #[test] + fn node_controls_override_run_model_controls() { + let backend = AgentApiBackend::new_from_env( + "gpt-5.4".to_string(), + Provider::OpenAi, + Vec::new(), + SteeringHub::for_tests(), + ) + .with_run_model_controls(fabro_types::settings::run::RunModelControls { + reasoning_effort: Some("low".to_string()), + speed: Some("fast".to_string()), + }); + let mut node = Node::new("work"); + node.attrs.insert( + "reasoning_effort".to_string(), + fabro_graphviz::graph::AttrValue::String("high".to_string()), + ); + node.attrs.insert( + "speed".to_string(), + fabro_graphviz::graph::AttrValue::String("standard".to_string()), + ); + + let controls = backend + .effective_request_controls("gpt-5.4", &node) + .unwrap(); + + assert_eq!(controls.reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!(controls.speed, Some(Speed::Standard)); + } + + #[test] + fn known_model_without_effort_omits_legacy_high_default() { + let backend = AgentApiBackend::new_from_env( + "kimi-k2.5".to_string(), + Provider::Kimi, + Vec::new(), + SteeringHub::for_tests(), + ); + let node = Node::new("work"); + + let controls = backend + .effective_request_controls("kimi-k2.5", &node) + .unwrap(); + + assert_eq!(controls.reasoning_effort, None); + } + #[tokio::test] async fn api_backend_uses_source_credentials() { let dir = tempfile::tempdir().unwrap(); diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index c1abdf69d..e5ae4e733 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -441,6 +441,7 @@ impl RunSession { profile_kind, fallback_chain, mcp_servers, + model_controls: resolved.model.controls.clone(), dry_run: resolved.execution.mode == RunMode::DryRun, }, interviewer, diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index 8196e29f4..61bc1850e 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -17,6 +17,7 @@ use fabro_hooks::HookSettings; use fabro_interview::AutoApproveInterviewer; use fabro_sandbox::SandboxSpec; use fabro_store::Database; +use fabro_types::settings::run::RunModelControls; use fabro_types::{Principal, RunId, SystemActorKind, WorkflowSettings, fixtures, format_blob_ref}; use object_store::memory::InMemory; @@ -252,6 +253,7 @@ async fn execute_test_run_with_options( profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), + model_controls: RunModelControls::default(), dry_run: true, }, interviewer: Arc::new(AutoApproveInterviewer::engine()), @@ -316,6 +318,7 @@ async fn execute_runs_start_to_exit_and_returns_final_context() { profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), + model_controls: RunModelControls::default(), dry_run: true, }, interviewer: Arc::new(AutoApproveInterviewer::engine()), @@ -395,6 +398,7 @@ async fn run_with_lifecycle( profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), + model_controls: RunModelControls::default(), dry_run: true, }, interviewer: Arc::new(AutoApproveInterviewer::engine()), diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index f36149386..fc3f5003b 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -156,6 +156,7 @@ async fn build_registry( let profile_kind = spec.profile_kind; let fallback_chain = spec.fallback_chain.clone(); let mcp_servers = spec.mcp_servers.clone(); + let model_controls = spec.model_controls.clone(); let llm_source_for_api = Arc::clone(&llm_source); let catalog_for_api = Arc::clone(&catalog); let steering_hub_for_api = Arc::clone(&steering_hub); @@ -172,6 +173,7 @@ async fn build_registry( Arc::clone(&steering_hub_for_api), Arc::clone(&catalog_for_api), ) + .with_run_model_controls(model_controls.clone()) .with_tool_env_provider(tool_env_provider.clone()) .with_mcp_servers(mcp_servers.clone()); let cli = cli_resolver @@ -717,6 +719,7 @@ mod tests { use fabro_model::catalog::LlmCatalogSettings; use fabro_sandbox::SandboxSpec; use fabro_store::Database; + use fabro_types::settings::run::RunModelControls; use fabro_types::{EventBody, RunEvent, RunId, WorkflowSettings, fixtures}; use fabro_vault::{SecretType, Vault}; use object_store::memory::InMemory; @@ -886,6 +889,7 @@ mod tests { profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), + model_controls: RunModelControls::default(), dry_run: true, }, interviewer: Arc::new(AutoApproveInterviewer::engine()), @@ -948,6 +952,7 @@ mod tests { profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), + model_controls: RunModelControls::default(), dry_run: true, }, interviewer: Arc::new(AutoApproveInterviewer::engine()), @@ -1044,6 +1049,7 @@ mod tests { profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), + model_controls: RunModelControls::default(), dry_run: false, }, Arc::new(AutoApproveInterviewer::engine()), @@ -1162,6 +1168,7 @@ mod tests { profile_kind: fabro_model::AgentProfileKind::OpenAi, fallback_chain: Vec::new(), mcp_servers: Vec::new(), + model_controls: RunModelControls::default(), dry_run: false, }, interviewer: Arc::new(AutoApproveInterviewer::engine()), @@ -1261,6 +1268,7 @@ mod tests { profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), + model_controls: RunModelControls::default(), dry_run: true, }, interviewer: Arc::new(AutoApproveInterviewer::engine()), @@ -1378,6 +1386,7 @@ mod tests { profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), + model_controls: RunModelControls::default(), dry_run: true, }, interviewer: Arc::new(AutoApproveInterviewer::engine()), @@ -1444,6 +1453,7 @@ mod tests { profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), + model_controls: RunModelControls::default(), dry_run: true, }, interviewer: Arc::new(AutoApproveInterviewer::engine()), diff --git a/lib/crates/fabro-workflow/src/pipeline/types.rs b/lib/crates/fabro-workflow/src/pipeline/types.rs index 39531ff1e..79622e6fb 100644 --- a/lib/crates/fabro-workflow/src/pipeline/types.rs +++ b/lib/crates/fabro-workflow/src/pipeline/types.rs @@ -9,7 +9,7 @@ use fabro_mcp::config::McpServerSettings; use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, ProviderId}; use fabro_sandbox::SandboxSpec; use fabro_types::RunId; -use fabro_types::settings::run::PullRequestSettings; +use fabro_types::settings::run::{PullRequestSettings, RunModelControls}; use fabro_validate::{Diagnostic, Severity}; use fabro_vault::Vault; use tokio::sync::RwLock as AsyncRwLock; @@ -224,6 +224,7 @@ pub struct LlmSpec { pub profile_kind: AgentProfileKind, pub fallback_chain: Vec, pub mcp_servers: Vec, + pub model_controls: RunModelControls, pub dry_run: bool, }