From 4df605e561d85d5334c40ac5dbff30ac4a21ccc2 Mon Sep 17 00:00:00 2001 From: "brynary-fabro[bot]" <265161896+brynary-fabro[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:49:16 -0400 Subject: [PATCH] Map `reasoning_effort` to Anthropic `output_config.effort` (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds support for mapping the unified `reasoning_effort` field to Anthropic's `output_config.effort` API parameter, which is the recommended way to control thinking depth for Claude Opus 4.6 and Sonnet 4.6 models. Previously, the Anthropic provider silently dropped `reasoning_effort` from requests, while the OpenAI provider already correctly mapped it to `reasoning: { effort }`. This inconsistency meant that workflow nodes setting `reasoning_effort: "high"` (the default) had no effect when routing through Anthropic. The change adds an `output_config: Option` field to the `ApiRequest` struct with `skip_serializing_if = "Option::is_none"` to ensure it's omitted when not set, then populates it in `build_api_request` by transforming `request.reasoning_effort` into `{"effort": }` — mirroring the pattern used in the OpenAI provider. All existing `ApiRequest` constructions in tests are updated to include `output_config: None`. The PR also takes the opportunity to refactor the test module by extracting a `make_base_request()` helper, which reduces boilerplate in `build_api_request_omits_whitespace_only_system_prompt` and `make_request_with_format` and makes the two new tests (`build_api_request_maps_reasoning_effort_to_output_config` and `build_api_request_omits_output_config_when_no_reasoning_effort`) easy to read. All existing provider tests continue to pass and no new Clippy warnings are introduced. ### Fabro Details
Ran 9 stages in 10m 28s for $1.48 | Stage | Duration | Cost | Retries | |---|---|---|---| | start | 0s | – | 0 | | toolchain | 0s | – | 0 | | preflight_compile | 1m 8s | – | 0 | | preflight_lint | 12s | – | 0 | | implement | 2m 13s | $0.49 | 0 | | simplify_opus | 4m 45s | $0.99 | 0 | | simplify_gpt | 0s | – | 0 | | verify | 1m 40s | – | 0 | | fmt | 1s | – | 0 | | **Total** | **10m 28s** | **$1.48** | **0** |
Ran ImplementAndSimplify.fabro (12 nodes and 15 edges) ```dot digraph ImplementAndSimplify { graph [ goal="Implement and simplify", model_stylesheet=" * { backend: api; model: claude-opus-4-6;} " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0] preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0] preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1", max_retries=0] fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3] implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."] simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"] simplify_gpt [label="Simplify (GPT-54)", prompt="@prompts/simplify.md", model="gpt-54"] verify [label="Verify", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1", goal_gate=true, retry_target="fixup"] fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3] fmt [label="Format", shape=parallelogram, script="cargo fmt --all 2>&1", max_retries=0] start -> toolchain toolchain -> preflight_compile [condition="outcome=success"] toolchain -> exit preflight_compile -> preflight_lint [condition="outcome=success"] preflight_compile -> exit preflight_lint -> implement [condition="outcome=success"] preflight_lint -> fix_lints fix_lints -> preflight_lint implement -> simplify_opus -> simplify_gpt -> verify verify -> fmt [condition="outcome=success"] verify -> fixup fixup -> verify fmt -> exit } ```
⚒️ Generated with [Fabro](https://fabro.sh) --------- Co-authored-by: Fabro --- .../fabro-llm/src/providers/anthropic.rs | 69 ++++++++++++++----- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/lib/crates/fabro-llm/src/providers/anthropic.rs b/lib/crates/fabro-llm/src/providers/anthropic.rs index 638b81e95..b96b8decc 100644 --- a/lib/crates/fabro-llm/src/providers/anthropic.rs +++ b/lib/crates/fabro-llm/src/providers/anthropic.rs @@ -107,6 +107,8 @@ struct ApiRequest { #[serde(skip_serializing_if = "Option::is_none")] thinking: Option, #[serde(skip_serializing_if = "Option::is_none")] + output_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] metadata: Option>, #[serde(skip_serializing_if = "std::ops::Not::not")] stream: bool, @@ -1072,6 +1074,11 @@ fn build_api_request( let thinking = extract_thinking_config(request.provider_options.as_ref()); + let output_config = request + .reasoning_effort + .as_ref() + .map(|effort| serde_json::json!({"effort": effort})); + let api_request = ApiRequest { model: request.model.clone(), messages: api_messages, @@ -1088,6 +1095,7 @@ fn build_api_request( tools: api_tools, tool_choice: tool_choice_json, thinking, + output_config, metadata: request.metadata.clone(), stream, }; @@ -1577,6 +1585,7 @@ mod tests { tools: None, tool_choice: None, thinking: None, + output_config: None, metadata: None, stream: false, }; @@ -1592,8 +1601,21 @@ mod tests { fn build_api_request_omits_whitespace_only_system_prompt() { let adapter = Adapter::new("test-key"); let request = Request { - model: "claude-sonnet-4-20250514".to_string(), messages: vec![Message::system(" \n\t"), Message::user("Hello")], + ..make_base_request() + }; + + let (api_request, _req_builder) = build_api_request(&adapter, &request, false); + assert!( + api_request.system.is_none(), + "whitespace-only system prompts should be omitted" + ); + } + + fn make_base_request() -> Request { + Request { + model: "claude-sonnet-4-20250514".to_string(), + messages: vec![Message::user("Hello")], provider: Some("anthropic".to_string()), tools: None, tool_choice: None, @@ -1605,30 +1627,15 @@ mod tests { reasoning_effort: None, metadata: None, provider_options: None, - }; - - let (api_request, _req_builder) = build_api_request(&adapter, &request, false); - assert!( - api_request.system.is_none(), - "whitespace-only system prompts should be omitted" - ); + } } fn make_request_with_format(format: crate::types::ResponseFormat) -> Request { Request { - model: "claude-sonnet-4-20250514".to_string(), - messages: vec![Message::user("Hello")], provider: None, - tools: None, - tool_choice: None, response_format: Some(format), - temperature: None, - top_p: None, max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - metadata: None, - provider_options: None, + ..make_base_request() } } @@ -1973,6 +1980,7 @@ mod tests { tools: None, tool_choice: None, thinking: None, + output_config: None, metadata: None, stream: false, }; @@ -2004,6 +2012,7 @@ mod tests { tools: None, tool_choice: None, thinking: None, + output_config: None, metadata: None, stream: false, }; @@ -2040,4 +2049,28 @@ mod tests { "[Audio content not supported by this provider]" ); } + + #[test] + fn build_api_request_maps_reasoning_effort_to_output_config() { + let adapter = Adapter::new("test-key"); + let request = Request { + reasoning_effort: Some("medium".to_string()), + ..make_base_request() + }; + + let (api_request, _req_builder) = build_api_request(&adapter, &request, false); + assert_eq!( + api_request.output_config, + Some(serde_json::json!({"effort": "medium"})) + ); + } + + #[test] + fn build_api_request_omits_output_config_when_no_reasoning_effort() { + let adapter = Adapter::new("test-key"); + let request = make_base_request(); + + let (api_request, _req_builder) = build_api_request(&adapter, &request, false); + assert!(api_request.output_config.is_none()); + } }