From cd7f0b1415fccee34dc66de46942fa857f62bac3 Mon Sep 17 00:00:00 2001 From: "brynary-fabro[bot]" <265161896+brynary-fabro[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 14:37:42 -0400 Subject: [PATCH] Support Anthropic fast mode (`speed: fast`) (#127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds support for Anthropic's fast mode beta feature (`speed: fast`), which enables up to 2.5x faster output for Claude Opus 4.6 at a 6x pricing premium. The implementation follows the same patterns established by `reasoning_effort`, threading a new `speed: Option` field through the request/response pipeline from graph stylesheet properties down through agent configuration, session management, and the Anthropic provider adapter. On the provider side, when `speed: "fast"` is set, the `ApiRequest` struct now includes the `speed` field in the serialized JSON body, and the `build_beta_header` function injects the required `anthropic-beta: fast-mode-2026-02-01` header alongside any existing beta headers (cache, interleaved thinking, etc.) without duplication. The response's `usage.speed` field is parsed and propagated back through both streaming and non-streaming paths into `StageUsage` and `Usage` types for tracking. Cost accounting applies a 6x multiplier in `compute_stage_cost` when `speed == "fast"`, reflecting Anthropic's actual pricing differential. The feature is configurable via stylesheet (`* { speed: fast; }`), which gets wired through `SessionConfig` and prompt-mode `Request` construction in the backend layer. New tests cover the API request serialization, beta header injection, combined cache+fast-mode headers, and the cost multiplier, while all existing test fixtures have been updated with `speed: None` to maintain struct exhaustiveness. ### Fabro Details
Ran 9 stages in 58m 27s for $6.56 | Stage | Duration | Cost | Retries | |---|---|---|---| | start | 0s | – | 0 | | toolchain | 0s | – | 0 | | preflight_compile | 1m 20s | – | 0 | | preflight_lint | 14s | – | 0 | | implement | 47m 31s | $5.36 | 0 | | simplify_opus | 7m 59s | $1.20 | 0 | | simplify_gpt | 0s | – | 0 | | verify | 25s | – | 0 | | fmt | 1s | – | 0 | | **Total** | **58m 27s** | **$6.56** | **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 --- lib/crates/fabro-agent/src/compaction.rs | 1 + lib/crates/fabro-agent/src/config.rs | 2 + lib/crates/fabro-agent/src/session.rs | 5 ++ lib/crates/fabro-agent/src/tools.rs | 1 + lib/crates/fabro-agent/src/types.rs | 1 + lib/crates/fabro-api/src/server.rs | 1 + lib/crates/fabro-cli/src/doctor.rs | 1 + lib/crates/fabro-graphviz/src/graph/types.rs | 6 ++ lib/crates/fabro-hooks/src/executor.rs | 1 + lib/crates/fabro-llm/src/client.rs | 1 + lib/crates/fabro-llm/src/generate.rs | 3 + .../fabro-llm/src/providers/anthropic.rs | 89 +++++++++++++++++-- .../fabro-llm/src/providers/fabro_server.rs | 1 + lib/crates/fabro-llm/src/providers/gemini.rs | 1 + lib/crates/fabro-llm/src/providers/openai.rs | 1 + .../src/providers/openai_compatible.rs | 1 + lib/crates/fabro-llm/src/types.rs | 10 +++ lib/crates/fabro-llm/tests/integration.rs | 2 + lib/crates/fabro-workflows/src/backend/api.rs | 4 + lib/crates/fabro-workflows/src/backend/cli.rs | 1 + lib/crates/fabro-workflows/src/cost.rs | 42 ++++++++- lib/crates/fabro-workflows/src/event.rs | 2 + lib/crates/fabro-workflows/src/outcome.rs | 5 ++ lib/crates/fabro-workflows/src/preamble.rs | 4 + lib/crates/fabro-workflows/src/stylesheet.rs | 16 +++- .../fabro-workflows/tests/integration.rs | 1 + 26 files changed, 192 insertions(+), 11 deletions(-) diff --git a/lib/crates/fabro-agent/src/compaction.rs b/lib/crates/fabro-agent/src/compaction.rs index 699a6bc5e..082c4b9b8 100644 --- a/lib/crates/fabro-agent/src/compaction.rs +++ b/lib/crates/fabro-agent/src/compaction.rs @@ -112,6 +112,7 @@ function names, error messages, and exact values. Omit pleasantries and conversa max_tokens: Some(4096), stop_sequences: None, reasoning_effort: None, + speed: None, metadata: None, provider_options: None, }; diff --git a/lib/crates/fabro-agent/src/config.rs b/lib/crates/fabro-agent/src/config.rs index 2758dab39..dff155c52 100644 --- a/lib/crates/fabro-agent/src/config.rs +++ b/lib/crates/fabro-agent/src/config.rs @@ -64,6 +64,7 @@ pub struct SessionConfig { pub default_command_timeout_ms: u64, pub max_command_timeout_ms: u64, pub reasoning_effort: Option, + pub speed: Option, pub tool_output_limits: HashMap, pub tool_line_limits: HashMap, /// Override the provider's default max_tokens when set. @@ -133,6 +134,7 @@ impl Default for SessionConfig { max_command_timeout_ms: 600_000, max_tokens: None, reasoning_effort: None, + speed: None, tool_output_limits: HashMap::new(), tool_line_limits: HashMap::new(), enable_loop_detection: true, diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index 4fd442dc4..dba09be50 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -459,6 +459,10 @@ impl Session { self.config.reasoning_effort = effort; } + pub fn set_speed(&mut self, speed: Option) { + self.config.speed = speed; + } + pub const fn set_max_turns(&mut self, max_turns: usize) { self.config.max_turns = max_turns; } @@ -906,6 +910,7 @@ impl Session { }), stop_sequences: None, reasoning_effort: self.config.reasoning_effort.clone(), + speed: self.config.speed.clone(), metadata: None, provider_options: self.provider_profile.provider_options(), } diff --git a/lib/crates/fabro-agent/src/tools.rs b/lib/crates/fabro-agent/src/tools.rs index f204dadb4..dc7ed3822 100644 --- a/lib/crates/fabro-agent/src/tools.rs +++ b/lib/crates/fabro-agent/src/tools.rs @@ -588,6 +588,7 @@ pub(crate) fn make_web_fetch_tool(summarizer: Option) -> Reg max_tokens: None, stop_sequences: None, reasoning_effort: None, + speed: None, metadata: None, provider_options: None, }; diff --git a/lib/crates/fabro-agent/src/types.rs b/lib/crates/fabro-agent/src/types.rs index 8fd178b80..2f7228d72 100644 --- a/lib/crates/fabro-agent/src/types.rs +++ b/lib/crates/fabro-agent/src/types.rs @@ -624,6 +624,7 @@ mod tests { cache_read_tokens: Some(80), cache_write_tokens: Some(10), reasoning_tokens: Some(20), + speed: None, raw: None, }; let event = AgentEvent::AssistantMessage { diff --git a/lib/crates/fabro-api/src/server.rs b/lib/crates/fabro-api/src/server.rs index 8da054ac8..083fcf67b 100644 --- a/lib/crates/fabro-api/src/server.rs +++ b/lib/crates/fabro-api/src/server.rs @@ -1232,6 +1232,7 @@ async fn create_completion( Some(req.stop_sequences) }, reasoning_effort: req.reasoning_effort, + speed: None, metadata: None, provider_options: req.provider_options, }; diff --git a/lib/crates/fabro-cli/src/doctor.rs b/lib/crates/fabro-cli/src/doctor.rs index 1747f40d5..955b6f0b9 100644 --- a/lib/crates/fabro-cli/src/doctor.rs +++ b/lib/crates/fabro-cli/src/doctor.rs @@ -877,6 +877,7 @@ async fn probe_llm_provider( max_tokens: Some(16), stop_sequences: None, reasoning_effort: None, + speed: None, metadata: None, provider_options: None, }; diff --git a/lib/crates/fabro-graphviz/src/graph/types.rs b/lib/crates/fabro-graphviz/src/graph/types.rs index d6cc8cb61..5da77102b 100644 --- a/lib/crates/fabro-graphviz/src/graph/types.rs +++ b/lib/crates/fabro-graphviz/src/graph/types.rs @@ -210,6 +210,11 @@ impl Node { self.str_attr("reasoning_effort").unwrap_or("high") } + #[must_use] + pub fn speed(&self) -> Option<&str> { + self.str_attr("speed") + } + #[must_use] pub fn auto_status(&self) -> bool { self.bool_attr("auto_status").unwrap_or(false) @@ -534,6 +539,7 @@ mod tests { assert_eq!(node.model(), None); assert_eq!(node.provider(), None); assert_eq!(node.reasoning_effort(), "high"); + assert_eq!(node.speed(), None); assert!(!node.auto_status()); assert!(!node.allow_partial()); assert_eq!(node.retry_policy(), None); diff --git a/lib/crates/fabro-hooks/src/executor.rs b/lib/crates/fabro-hooks/src/executor.rs index 4d63e6da8..bfb903067 100644 --- a/lib/crates/fabro-hooks/src/executor.rs +++ b/lib/crates/fabro-hooks/src/executor.rs @@ -343,6 +343,7 @@ impl HookExecutorImpl { max_tokens: None, stop_sequences: None, reasoning_effort: None, + speed: None, metadata: None, provider_options: None, }; diff --git a/lib/crates/fabro-llm/src/client.rs b/lib/crates/fabro-llm/src/client.rs index 5fb603375..00b819936 100644 --- a/lib/crates/fabro-llm/src/client.rs +++ b/lib/crates/fabro-llm/src/client.rs @@ -339,6 +339,7 @@ mod tests { max_tokens: None, stop_sequences: None, reasoning_effort: None, + speed: None, metadata: None, provider_options: None, } diff --git a/lib/crates/fabro-llm/src/generate.rs b/lib/crates/fabro-llm/src/generate.rs index 807a41f58..815feb791 100644 --- a/lib/crates/fabro-llm/src/generate.rs +++ b/lib/crates/fabro-llm/src/generate.rs @@ -69,6 +69,7 @@ fn build_request( max_tokens: params.max_tokens, stop_sequences: params.stop_sequences.clone(), reasoning_effort: params.reasoning_effort.clone(), + speed: params.speed.clone(), metadata: params.metadata.clone(), provider_options: params.provider_options.clone(), } @@ -288,6 +289,7 @@ pub struct GenerateParams { pub max_tokens: Option, pub stop_sequences: Option>, pub reasoning_effort: Option, + pub speed: Option, pub provider: Option, pub provider_options: Option, pub metadata: Option>, @@ -318,6 +320,7 @@ impl GenerateParams { max_tokens: None, stop_sequences: None, reasoning_effort: None, + speed: None, provider: None, provider_options: None, metadata: None, diff --git a/lib/crates/fabro-llm/src/providers/anthropic.rs b/lib/crates/fabro-llm/src/providers/anthropic.rs index 0ed91b0b5..15c4329d2 100644 --- a/lib/crates/fabro-llm/src/providers/anthropic.rs +++ b/lib/crates/fabro-llm/src/providers/anthropic.rs @@ -109,6 +109,8 @@ struct ApiRequest { #[serde(skip_serializing_if = "Option::is_none")] output_config: Option, #[serde(skip_serializing_if = "Option::is_none")] + speed: Option, + #[serde(skip_serializing_if = "Option::is_none")] metadata: Option>, #[serde(skip_serializing_if = "std::ops::Not::not")] stream: bool, @@ -165,6 +167,8 @@ struct ApiUsage { cache_read_input_tokens: Option, #[serde(default)] cache_creation_input_tokens: Option, + #[serde(default)] + speed: Option, } /// Estimate reasoning tokens from thinking content blocks. @@ -508,6 +512,7 @@ fn convert_stream_event_for_json_schema(event: StreamEvent) -> StreamEvent { // --- Prompt caching helpers --- const CACHE_BETA_HEADER: &str = "prompt-caching-2024-07-31"; +const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; /// Check whether auto-caching is disabled via `provider_options`. /// @@ -594,6 +599,7 @@ fn apply_cache_control_to_conversation_prefix(messages: &mut [ApiMessage]) { fn build_beta_header( provider_options: Option<&serde_json::Value>, include_cache_header: bool, + include_fast_mode_header: bool, ) -> Option { let mut headers: Vec = Vec::new(); @@ -616,6 +622,11 @@ fn build_beta_header( headers.push(CACHE_BETA_HEADER.to_string()); } + // Add fast-mode header if speed=fast and not already present + if include_fast_mode_header && !headers.iter().any(|h| h == FAST_MODE_BETA_HEADER) { + headers.push(FAST_MODE_BETA_HEADER.to_string()); + } + if headers.is_empty() { None } else { @@ -710,6 +721,10 @@ impl StreamAccumulator { self.usage.cache_write_tokens = usage .get("cache_creation_input_tokens") .and_then(serde_json::Value::as_i64); + self.usage.speed = usage + .get("speed") + .and_then(serde_json::Value::as_str) + .map(String::from); } } vec![StreamEvent::StreamStart] @@ -1122,6 +1137,8 @@ fn build_api_request( (explicit_thinking, None) }; + let is_fast = request.speed.as_deref() == Some("fast"); + let api_request = ApiRequest { model: request.model.clone(), messages: api_messages, @@ -1134,6 +1151,7 @@ fn build_api_request( tool_choice: tool_choice_json, thinking, output_config, + speed: request.speed.clone(), metadata: request.metadata.clone(), stream, }; @@ -1150,7 +1168,9 @@ fn build_api_request( .header("x-api-key", &adapter.http.api_key) .header("anthropic-version", "2023-06-01"); - if let Some(beta_str) = build_beta_header(request.provider_options.as_ref(), auto_cache) { + if let Some(beta_str) = + build_beta_header(request.provider_options.as_ref(), auto_cache, is_fast) + { req_builder = req_builder.header("anthropic-beta", beta_str); } } else { @@ -1234,6 +1254,7 @@ impl ProviderAdapter for Adapter { reasoning_tokens, cache_read_tokens: api_resp.usage.cache_read_input_tokens, cache_write_tokens: api_resp.usage.cache_creation_input_tokens, + speed: api_resp.usage.speed, ..Usage::default() }, raw: serde_json::from_str(&body).ok(), @@ -1527,13 +1548,13 @@ mod tests { #[test] fn beta_header_includes_cache_header() { - let result = build_beta_header(None, true); + let result = build_beta_header(None, true, false); assert_eq!(result, Some(CACHE_BETA_HEADER.to_string())); } #[test] fn beta_header_no_cache_no_user_headers() { - let result = build_beta_header(None, false); + let result = build_beta_header(None, false, false); assert_eq!(result, None); } @@ -1544,7 +1565,7 @@ mod tests { "beta_headers": ["interleaved-thinking-2025-05-14"] } }); - let result = build_beta_header(Some(&opts), true); + let result = build_beta_header(Some(&opts), true, false); assert_eq!( result, Some(format!( @@ -1560,7 +1581,7 @@ mod tests { "beta_headers": [CACHE_BETA_HEADER] } }); - let result = build_beta_header(Some(&opts), true); + let result = build_beta_header(Some(&opts), true, false); // Should not duplicate the header assert_eq!(result, Some(CACHE_BETA_HEADER.to_string())); } @@ -1572,7 +1593,7 @@ mod tests { "beta_headers": ["interleaved-thinking-2025-05-14"] } }); - let result = build_beta_header(Some(&opts), false); + let result = build_beta_header(Some(&opts), false, false); assert_eq!(result, Some("interleaved-thinking-2025-05-14".to_string())); } @@ -1624,6 +1645,7 @@ mod tests { tool_choice: None, thinking: None, output_config: None, + speed: None, metadata: None, stream: false, }; @@ -1663,6 +1685,7 @@ mod tests { max_tokens: Some(128), stop_sequences: None, reasoning_effort: None, + speed: None, metadata: None, provider_options: None, } @@ -1979,7 +2002,7 @@ mod tests { ]; // No user headers — only cache header should appear - let header = build_beta_header(None, true).unwrap_or_default(); + let header = build_beta_header(None, true, false).unwrap_or_default(); for dep in &deprecated { assert!( !header.contains(dep), @@ -1993,7 +2016,7 @@ mod tests { "beta_headers": ["interleaved-thinking-2025-05-14"] } }); - let header = build_beta_header(Some(&opts), true).unwrap_or_default(); + let header = build_beta_header(Some(&opts), true, false).unwrap_or_default(); for dep in &deprecated { assert!( !header.contains(dep), @@ -2019,6 +2042,7 @@ mod tests { tool_choice: None, thinking: None, output_config: None, + speed: None, metadata: None, stream: false, }; @@ -2051,6 +2075,7 @@ mod tests { tool_choice: None, thinking: None, output_config: None, + speed: None, metadata: None, stream: false, }; @@ -2111,4 +2136,52 @@ mod tests { let (api_request, _req_builder) = build_api_request(&adapter, &request, false); assert!(api_request.output_config.is_none()); } + + #[test] + fn build_api_request_sets_speed() { + let adapter = Adapter::new("test-key"); + let request = Request { + speed: Some("fast".to_string()), + ..make_base_request() + }; + + let (api_request, _req_builder) = build_api_request(&adapter, &request, false); + assert_eq!(api_request.speed, Some("fast".to_string())); + } + + #[test] + fn build_api_request_injects_fast_mode_beta_header() { + let adapter = Adapter::new("test-key"); + let request = Request { + speed: Some("fast".to_string()), + ..make_base_request() + }; + + let (_api_request, req_builder) = build_api_request(&adapter, &request, false); + let built = req_builder.build().expect("should build request"); + let beta = built + .headers() + .get("anthropic-beta") + .expect("anthropic-beta header should be present") + .to_str() + .unwrap(); + assert!( + beta.contains(FAST_MODE_BETA_HEADER), + "beta header should contain fast-mode header, got: {beta}" + ); + } + + #[test] + fn beta_header_includes_both_cache_and_fast_mode() { + let result = build_beta_header(None, true, true); + let header = result.expect("should produce a header"); + assert!( + header.contains(CACHE_BETA_HEADER), + "should contain cache header" + ); + assert!( + header.contains(FAST_MODE_BETA_HEADER), + "should contain fast-mode header" + ); + } } diff --git a/lib/crates/fabro-llm/src/providers/fabro_server.rs b/lib/crates/fabro-llm/src/providers/fabro_server.rs index 62e66fa2d..604176f8b 100644 --- a/lib/crates/fabro-llm/src/providers/fabro_server.rs +++ b/lib/crates/fabro-llm/src/providers/fabro_server.rs @@ -245,6 +245,7 @@ mod tests { max_tokens: None, stop_sequences: None, reasoning_effort: None, + speed: None, metadata: None, provider_options: None, } diff --git a/lib/crates/fabro-llm/src/providers/gemini.rs b/lib/crates/fabro-llm/src/providers/gemini.rs index 306d39990..ef73894d4 100644 --- a/lib/crates/fabro-llm/src/providers/gemini.rs +++ b/lib/crates/fabro-llm/src/providers/gemini.rs @@ -976,6 +976,7 @@ mod tests { max_tokens: None, stop_sequences: None, reasoning_effort: None, + speed: None, metadata: None, provider_options: None, } diff --git a/lib/crates/fabro-llm/src/providers/openai.rs b/lib/crates/fabro-llm/src/providers/openai.rs index 95c5bffad..54bf2f862 100644 --- a/lib/crates/fabro-llm/src/providers/openai.rs +++ b/lib/crates/fabro-llm/src/providers/openai.rs @@ -1085,6 +1085,7 @@ mod tests { max_tokens: None, stop_sequences: None, reasoning_effort: None, + speed: None, metadata: None, provider_options: None, } diff --git a/lib/crates/fabro-llm/src/providers/openai_compatible.rs b/lib/crates/fabro-llm/src/providers/openai_compatible.rs index 77958abee..b54ed041a 100644 --- a/lib/crates/fabro-llm/src/providers/openai_compatible.rs +++ b/lib/crates/fabro-llm/src/providers/openai_compatible.rs @@ -1296,6 +1296,7 @@ mod tests { max_tokens: None, stop_sequences: None, reasoning_effort: None, + speed: None, metadata: None, provider_options: None, } diff --git a/lib/crates/fabro-llm/src/types.rs b/lib/crates/fabro-llm/src/types.rs index 05f782459..54bdf2c89 100644 --- a/lib/crates/fabro-llm/src/types.rs +++ b/lib/crates/fabro-llm/src/types.rs @@ -370,6 +370,8 @@ pub struct Usage { #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_write_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub raw: Option, } @@ -393,6 +395,7 @@ impl std::ops::Add for Usage { reasoning_tokens: add_optional(self.reasoning_tokens, rhs.reasoning_tokens), cache_read_tokens: add_optional(self.cache_read_tokens, rhs.cache_read_tokens), cache_write_tokens: add_optional(self.cache_write_tokens, rhs.cache_write_tokens), + speed: self.speed, raw: None, } } @@ -452,6 +455,7 @@ pub struct Request { pub max_tokens: Option, pub stop_sequences: Option>, pub reasoning_effort: Option, + pub speed: Option, pub metadata: Option>, pub provider_options: Option, } @@ -888,6 +892,7 @@ mod tests { reasoning_tokens: None, cache_read_tokens: None, cache_write_tokens: None, + speed: None, raw: None, }; insta::assert_snapshot!(serde_json::to_string_pretty(&usage).unwrap(), @r#" @@ -908,6 +913,7 @@ mod tests { reasoning_tokens: Some(20), cache_read_tokens: Some(80), cache_write_tokens: Some(10), + speed: None, raw: None, }; insta::assert_snapshot!(serde_json::to_string_pretty(&usage).unwrap(), @r#" @@ -940,6 +946,7 @@ mod tests { reasoning_tokens: Some(5), cache_read_tokens: Some(3), cache_write_tokens: Some(1), + speed: None, raw: None, }; let b = Usage { @@ -949,6 +956,7 @@ mod tests { reasoning_tokens: Some(10), cache_read_tokens: Some(7), cache_write_tokens: Some(2), + speed: None, raw: None, }; let sum = a + b; @@ -969,6 +977,7 @@ mod tests { reasoning_tokens: Some(5), cache_read_tokens: None, cache_write_tokens: None, + speed: None, raw: None, }; let b = Usage { @@ -978,6 +987,7 @@ mod tests { reasoning_tokens: None, cache_read_tokens: Some(7), cache_write_tokens: None, + speed: None, raw: None, }; let sum = a + b; diff --git a/lib/crates/fabro-llm/tests/integration.rs b/lib/crates/fabro-llm/tests/integration.rs index 2aa8255d2..a81118c3f 100644 --- a/lib/crates/fabro-llm/tests/integration.rs +++ b/lib/crates/fabro-llm/tests/integration.rs @@ -15,6 +15,7 @@ fn make_request(model: &str) -> Request { max_tokens: Some(50), stop_sequences: None, reasoning_effort: None, + speed: None, metadata: None, provider_options: None, } @@ -138,6 +139,7 @@ async fn run_multi_turn_cache_test( max_tokens: Some(100), stop_sequences: None, reasoning_effort: None, + speed: None, metadata: None, provider_options: None, }; diff --git a/lib/crates/fabro-workflows/src/backend/api.rs b/lib/crates/fabro-workflows/src/backend/api.rs index cc751bab3..d54ccb73a 100644 --- a/lib/crates/fabro-workflows/src/backend/api.rs +++ b/lib/crates/fabro-workflows/src/backend/api.rs @@ -195,6 +195,7 @@ impl AgentApiBackend { let config = SessionConfig { max_tokens: node.max_tokens(), reasoning_effort: Some(node.reasoning_effort().to_string()), + speed: node.speed().map(String::from), tool_hooks, mcp_servers, ..SessionConfig::default() @@ -283,6 +284,7 @@ impl CodergenBackend for AgentApiBackend { messages, provider, reasoning_effort: Some(node.reasoning_effort().to_string()), + speed: node.speed().map(String::from), tools: None, tool_choice: None, response_format: None, @@ -393,6 +395,7 @@ impl CodergenBackend for AgentApiBackend { cache_read_tokens: response.usage.cache_read_tokens, cache_write_tokens: response.usage.cache_write_tokens, reasoning_tokens: response.usage.reasoning_tokens, + speed: response.usage.speed.clone(), cost: None, }; stage_usage.cost = compute_stage_cost(&stage_usage); @@ -593,6 +596,7 @@ impl CodergenBackend for AgentApiBackend { cache_read_tokens: total_usage.cache_read_tokens, cache_write_tokens: total_usage.cache_write_tokens, reasoning_tokens: total_usage.reasoning_tokens, + speed: total_usage.speed.clone(), cost: None, }; stage_usage.cost = compute_stage_cost(&stage_usage); diff --git a/lib/crates/fabro-workflows/src/backend/cli.rs b/lib/crates/fabro-workflows/src/backend/cli.rs index 16af8e6dd..4e66884c7 100644 --- a/lib/crates/fabro-workflows/src/backend/cli.rs +++ b/lib/crates/fabro-workflows/src/backend/cli.rs @@ -706,6 +706,7 @@ impl CodergenBackend for AgentCliBackend { cache_read_tokens: None, cache_write_tokens: None, reasoning_tokens: None, + speed: None, cost: None, }; stage_usage.cost = compute_stage_cost(&stage_usage); diff --git a/lib/crates/fabro-workflows/src/cost.rs b/lib/crates/fabro-workflows/src/cost.rs index 09200322b..0020e19a6 100644 --- a/lib/crates/fabro-workflows/src/cost.rs +++ b/lib/crates/fabro-workflows/src/cost.rs @@ -6,9 +6,15 @@ pub fn compute_stage_cost(usage: &StageUsage) -> Option { let info = fabro_model::get_model_info(&usage.model)?; let input_rate = info.costs.input_cost_per_mtok?; let output_rate = info.costs.output_cost_per_mtok?; + let multiplier = if usage.speed.as_deref() == Some("fast") { + 6.0 + } else { + 1.0 + }; Some( - usage.input_tokens as f64 * input_rate / 1_000_000.0 - + usage.output_tokens as f64 * output_rate / 1_000_000.0, + (usage.input_tokens as f64 * input_rate / 1_000_000.0 + + usage.output_tokens as f64 * output_rate / 1_000_000.0) + * multiplier, ) } @@ -47,6 +53,7 @@ mod tests { cache_read_tokens: None, cache_write_tokens: None, reasoning_tokens: None, + speed: None, cost: None, }; let cost = compute_stage_cost(&usage); @@ -63,8 +70,39 @@ mod tests { cache_read_tokens: None, cache_write_tokens: None, reasoning_tokens: None, + speed: None, cost: None, }; assert_eq!(compute_stage_cost(&usage), None); } + + #[test] + fn compute_stage_cost_fast_mode_6x_multiplier() { + let standard_usage = StageUsage { + model: "claude-sonnet-4-5".into(), + input_tokens: 1000, + output_tokens: 500, + cache_read_tokens: None, + cache_write_tokens: None, + reasoning_tokens: None, + speed: None, + cost: None, + }; + let fast_usage = StageUsage { + model: "claude-sonnet-4-5".into(), + input_tokens: 1000, + output_tokens: 500, + cache_read_tokens: None, + cache_write_tokens: None, + reasoning_tokens: None, + speed: Some("fast".into()), + cost: None, + }; + let standard_cost = compute_stage_cost(&standard_usage).unwrap(); + let fast_cost = compute_stage_cost(&fast_usage).unwrap(); + assert!( + (fast_cost - standard_cost * 6.0).abs() < 1e-10, + "fast mode should be 6x standard cost: standard={standard_cost}, fast={fast_cost}" + ); + } } diff --git a/lib/crates/fabro-workflows/src/event.rs b/lib/crates/fabro-workflows/src/event.rs index 03c7d3bd0..e8bf71f06 100644 --- a/lib/crates/fabro-workflows/src/event.rs +++ b/lib/crates/fabro-workflows/src/event.rs @@ -1199,6 +1199,7 @@ mod tests { cache_read_tokens: Some(800), cache_write_tokens: Some(50), reasoning_tokens: Some(100), + speed: None, raw: None, }, tool_call_count: 3, @@ -2286,6 +2287,7 @@ mod tests { cache_read_tokens: Some(3000), cache_write_tokens: Some(500), reasoning_tokens: Some(800), + speed: None, raw: None, }), }; diff --git a/lib/crates/fabro-workflows/src/outcome.rs b/lib/crates/fabro-workflows/src/outcome.rs index 25804112c..bc6313e09 100644 --- a/lib/crates/fabro-workflows/src/outcome.rs +++ b/lib/crates/fabro-workflows/src/outcome.rs @@ -58,6 +58,8 @@ pub struct StageUsage { #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub cost: Option, } @@ -70,6 +72,7 @@ impl From<&StageUsage> for fabro_llm::types::Usage { cache_read_tokens: u.cache_read_tokens, cache_write_tokens: u.cache_write_tokens, reasoning_tokens: u.reasoning_tokens, + speed: u.speed.clone(), raw: None, } } @@ -378,6 +381,7 @@ mod tests { cache_read_tokens: Some(800), cache_write_tokens: Some(50), reasoning_tokens: Some(100), + speed: None, cost: None, }; let json = serde_json::to_string(&usage).unwrap(); @@ -398,6 +402,7 @@ mod tests { cache_read_tokens: None, cache_write_tokens: None, reasoning_tokens: None, + speed: None, cost: None, }; let json = serde_json::to_string(&usage).unwrap(); diff --git a/lib/crates/fabro-workflows/src/preamble.rs b/lib/crates/fabro-workflows/src/preamble.rs index ee8a8ab73..f000da97f 100644 --- a/lib/crates/fabro-workflows/src/preamble.rs +++ b/lib/crates/fabro-workflows/src/preamble.rs @@ -924,6 +924,7 @@ mod tests { cache_read_tokens: None, cache_write_tokens: None, reasoning_tokens: None, + speed: None, cost: None, }); outcome.files_touched = vec!["src/lib.rs".to_string(), "src/main.rs".to_string()]; @@ -1190,6 +1191,7 @@ mod tests { cache_read_tokens: None, cache_write_tokens: None, reasoning_tokens: None, + speed: None, cost: None, }); node_outcomes.insert("report".to_string(), outcome); @@ -1369,6 +1371,7 @@ mod tests { cache_read_tokens: None, cache_write_tokens: None, reasoning_tokens: None, + speed: None, cost: None, }); outcome.files_touched = vec!["src/lib.rs".to_string()]; @@ -1588,6 +1591,7 @@ mod tests { cache_read_tokens: None, cache_write_tokens: None, reasoning_tokens: None, + speed: None, cost: None, }); outcome.files_touched = vec!["src/lib.rs".to_string()]; diff --git a/lib/crates/fabro-workflows/src/stylesheet.rs b/lib/crates/fabro-workflows/src/stylesheet.rs index 31fddb2d9..37a3cdd8e 100644 --- a/lib/crates/fabro-workflows/src/stylesheet.rs +++ b/lib/crates/fabro-workflows/src/stylesheet.rs @@ -2,7 +2,8 @@ use fabro_graphviz::graph::{AttrValue, Graph}; pub use fabro_graphviz::stylesheet::{parse_stylesheet, Declaration, Rule, Selector, Stylesheet}; /// Recognized stylesheet properties. -const STYLESHEET_PROPERTIES: &[&str] = &["model", "provider", "reasoning_effort", "backend"]; +const STYLESHEET_PROPERTIES: &[&str] = + &["model", "provider", "reasoning_effort", "speed", "backend"]; /// Apply a stylesheet to a graph. Rules are applied by specificity order; /// higher specificity wins. Explicit node attributes are never overridden. @@ -240,4 +241,17 @@ mod tests { Some(&AttrValue::String("api".into())) ); } + + #[test] + fn apply_speed_property() { + let ss = parse_stylesheet("* { speed: fast; }").unwrap(); + let mut graph = Graph::new("test"); + graph.nodes.insert("a".into(), Node::new("a")); + apply_stylesheet(&ss, &mut graph); + + assert_eq!( + graph.nodes["a"].attrs.get("speed"), + Some(&AttrValue::String("fast".into())) + ); + } } diff --git a/lib/crates/fabro-workflows/tests/integration.rs b/lib/crates/fabro-workflows/tests/integration.rs index 6db83366c..56b6336cd 100644 --- a/lib/crates/fabro-workflows/tests/integration.rs +++ b/lib/crates/fabro-workflows/tests/integration.rs @@ -6071,6 +6071,7 @@ mod real_llm { max_tokens: Some(200), stop_sequences: None, reasoning_effort: None, + speed: None, metadata: None, provider_options: None, };