From f72f3fb97220f6a2bf73cb2a6d0b750da7b3fe3b Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Thu, 28 May 2026 15:30:22 -0400 Subject: [PATCH] feat(workflow): typed openrouter_* stage attrs for OR routing controls Adds OpenRouterOptions and two strum-derived enums (OpenRouterProviderSort, OpenRouterDataCollection) to fabro-llm. The workflow handler parses five new stage attrs into the struct and serializes them into the nested {provider: {...}, models: [...], transforms: [...]} JSON shape OpenRouter expects, embedded under Request.provider_options["openrouter"]: - openrouter_provider_sort: "price" | "throughput" | "latency" - openrouter_fallback_models: comma-separated model id list - openrouter_transforms: comma-separated transform name list - openrouter_allow_fallbacks: bool - openrouter_data_collection: "allow" | "deny" The existing merge_provider_options path in openai_chat::request pastes the json into the outgoing request body, so this works through the generic plumbing without OpenRouter-specific code in the shared module. EffectiveRequestControls loses Copy (Vec fields don't qualify); complete_one_shot_request now takes a reference. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-llm/src/types.rs | 183 +++++++++++++++++ .../fabro-workflow/src/handler/llm/api.rs | 186 +++++++++++++++++- .../fabro-workflow/src/handler/llm/router.rs | 1 + .../fabro-workflow/src/handler/prompt.rs | 2 + 4 files changed, 366 insertions(+), 6 deletions(-) diff --git a/lib/crates/fabro-llm/src/types.rs b/lib/crates/fabro-llm/src/types.rs index 515c5363d..db330139c 100644 --- a/lib/crates/fabro-llm/src/types.rs +++ b/lib/crates/fabro-llm/src/types.rs @@ -222,6 +222,121 @@ pub struct RateLimitInfo { // replacement types, and the LLM client share one enum. pub use fabro_model::ReasoningEffort; +// --- 3.14 OpenRouterOptions --- + +/// Strongly typed OpenRouter-specific request options. +/// +/// Populated by typed workflow attrs (`openrouter_provider_sort`, +/// `openrouter_fallback_models`, etc.) in the workflow handler. +/// Serializes into the nested `{provider: {...}, models: [...], +/// transforms: [...]}` JSON shape that OpenRouter accepts as top-level +/// request fields, and is merged into the body via the existing +/// `provider_options.openrouter` plumbing. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OpenRouterOptions { + pub provider_sort: Option, + pub allow_fallbacks: Option, + pub data_collection: Option, + pub models: Vec, + pub transforms: Vec, +} + +/// Provider routing sort order for OpenRouter. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Serialize, + Deserialize, + strum::Display, + strum::EnumString, + strum::IntoStaticStr, + strum::VariantArray, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum OpenRouterProviderSort { + Price, + Throughput, + Latency, +} + +/// Provider data-collection policy for OpenRouter. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Serialize, + Deserialize, + strum::Display, + strum::EnumString, + strum::IntoStaticStr, + strum::VariantArray, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum OpenRouterDataCollection { + Allow, + Deny, +} + +impl OpenRouterOptions { + /// True when no fields are set (all defaults). Used to skip emitting + /// `provider_options.openrouter = {}` when nothing was specified. + #[must_use] + pub fn is_empty(&self) -> bool { + self.provider_sort.is_none() + && self.allow_fallbacks.is_none() + && self.data_collection.is_none() + && self.models.is_empty() + && self.transforms.is_empty() + } + + /// Serialize into the nested OpenRouter JSON shape suitable for + /// embedding under `provider_options.openrouter`. + /// + /// Produces: + /// ```json + /// { + /// "provider": { "sort": "...", "allow_fallbacks": true, "data_collection": "..." }, + /// "models": [...], + /// "transforms": [...] + /// } + /// ``` + /// Fields that are unset are omitted. + #[must_use] + pub fn to_request_json(&self) -> serde_json::Value { + let mut provider = serde_json::Map::new(); + if let Some(sort) = self.provider_sort { + provider.insert("sort".into(), serde_json::json!(<&str>::from(sort))); + } + if let Some(allow) = self.allow_fallbacks { + provider.insert("allow_fallbacks".into(), serde_json::json!(allow)); + } + if let Some(dc) = self.data_collection { + provider.insert( + "data_collection".into(), + serde_json::json!(<&str>::from(dc)), + ); + } + let mut out = serde_json::Map::new(); + if !provider.is_empty() { + out.insert("provider".into(), serde_json::Value::Object(provider)); + } + if !self.models.is_empty() { + out.insert("models".into(), serde_json::json!(self.models)); + } + if !self.transforms.is_empty() { + out.insert("transforms".into(), serde_json::json!(self.transforms)); + } + serde_json::Value::Object(out) + } +} + // --- 3.6 Request --- #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1126,4 +1241,72 @@ mod tests { fn tool_choice_mode_str_named() { assert_eq!(ToolChoice::named("get_weather").mode_str(), "named"); } + + #[test] + fn openrouter_options_to_request_json_empty_yields_empty_object() { + let opts = OpenRouterOptions::default(); + assert!(opts.is_empty()); + let json = opts.to_request_json(); + assert_eq!(json, serde_json::json!({})); + } + + #[test] + fn openrouter_options_to_request_json_with_sort_nests_under_provider() { + let opts = OpenRouterOptions { + provider_sort: Some(OpenRouterProviderSort::Throughput), + ..OpenRouterOptions::default() + }; + assert!(!opts.is_empty()); + let json = opts.to_request_json(); + assert_eq!( + json, + serde_json::json!({ + "provider": { "sort": "throughput" } + }) + ); + } + + #[test] + fn openrouter_options_to_request_json_with_models_and_transforms() { + let opts = OpenRouterOptions { + models: vec![ + "openai/gpt-5.5".to_string(), + "google/gemini-3.1-pro-preview".to_string(), + ], + transforms: vec!["middle-out".to_string()], + ..OpenRouterOptions::default() + }; + let json = opts.to_request_json(); + assert_eq!( + json, + serde_json::json!({ + "models": ["openai/gpt-5.5", "google/gemini-3.1-pro-preview"], + "transforms": ["middle-out"], + }) + ); + } + + #[test] + fn openrouter_options_to_request_json_full_shape() { + let opts = OpenRouterOptions { + provider_sort: Some(OpenRouterProviderSort::Latency), + allow_fallbacks: Some(false), + data_collection: Some(OpenRouterDataCollection::Deny), + models: vec!["a".to_string(), "b".to_string()], + transforms: vec!["middle-out".to_string()], + }; + let json = opts.to_request_json(); + assert_eq!( + json, + serde_json::json!({ + "provider": { + "sort": "latency", + "allow_fallbacks": false, + "data_collection": "deny", + }, + "models": ["a", "b"], + "transforms": ["middle-out"], + }) + ); + } } diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index 91753f4c9..0fdd06ee6 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -14,8 +14,8 @@ use fabro_auth::{CredentialSource, EnvCredentialSource}; use fabro_graphviz::graph::{AttrValue, Node}; use fabro_llm::client::Client; use fabro_llm::types::{ - Message, ReasoningEffort, Request, Response, Speed, TokenCounts, - ToolDefinition as LlmToolDefinition, + Message, OpenRouterDataCollection, OpenRouterOptions, OpenRouterProviderSort, ReasoningEffort, + Request, Response, Speed, TokenCounts, ToolDefinition as LlmToolDefinition, }; use fabro_mcp::config::McpServerSettings; #[cfg(test)] @@ -114,10 +114,13 @@ enum AgentApiErrorDisposition { Terminal(Error), } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct EffectiveRequestControls { pub(crate) reasoning_effort: Option, pub(crate) speed: Option, + /// OpenRouter-specific options sourced from typed `openrouter_*` stage + /// attrs. `None` when no `openrouter_*` attr is set on the node. + pub(crate) openrouter: Option, } fn classify_agent_error(err: fabro_agent::Error, allow_failover: bool) -> AgentApiErrorDisposition { @@ -394,13 +397,88 @@ pub(crate) fn effective_request_controls( .or(run_model_controls.speed.as_deref()) .map(|value| parse_speed(node, value)) .transpose()?; + let openrouter = parse_openrouter_options(node)?; Ok(EffectiveRequestControls { reasoning_effort, speed, + openrouter, }) } +/// Parse the `openrouter_*` stage attrs into a typed [`OpenRouterOptions`]. +/// Returns `Ok(None)` when no `openrouter_*` attr is set on the node. +fn parse_openrouter_options(node: &Node) -> Result, Error> { + let mut opts = OpenRouterOptions::default(); + let mut any = false; + + if let Some(value) = control_attr(node, "openrouter_provider_sort") { + any = true; + opts.provider_sort = Some(value.parse::().map_err(|source| { + Error::handler_with_source( + format!( + "Invalid openrouter_provider_sort \"{value}\" for node \"{}\"; expected one of: price, throughput, latency", + node.id, + ), + source, + ) + })?); + } + + if let Some(value) = control_attr(node, "openrouter_data_collection") { + any = true; + opts.data_collection = Some(value.parse::().map_err(|source| { + Error::handler_with_source( + format!( + "Invalid openrouter_data_collection \"{value}\" for node \"{}\"; expected one of: allow, deny", + node.id, + ), + source, + ) + })?); + } + + if let Some(value) = control_attr(node, "openrouter_allow_fallbacks") { + any = true; + opts.allow_fallbacks = Some(value.parse::().map_err(|source| { + Error::handler_with_source( + format!( + "Invalid openrouter_allow_fallbacks \"{value}\" for node \"{}\"; expected true or false", + node.id, + ), + source, + ) + })?); + } + + if let Some(value) = control_attr(node, "openrouter_fallback_models") { + let models = split_csv(value); + if !models.is_empty() { + any = true; + opts.models = models; + } + } + + if let Some(value) = control_attr(node, "openrouter_transforms") { + let transforms = split_csv(value); + if !transforms.is_empty() { + any = true; + opts.transforms = transforms; + } + } + + Ok(if any { Some(opts) } else { None }) +} + +fn split_csv(value: &str) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect() +} + fn control_attr<'a>(node: &'a Node, key: &str) -> Option<&'a str> { node.attrs.get(key).and_then(AttrValue::as_str) } @@ -921,7 +999,7 @@ impl AgentApiBackend { emitter: &Arc, stage_scope: &StageScope, request: &Request, - controls: EffectiveRequestControls, + controls: &EffectiveRequestControls, fallback_chain: &[FallbackTarget], ) -> Result { let result = client.complete(request).await; @@ -1075,7 +1153,10 @@ impl CodergenBackend for AgentApiBackend { max_tokens, stop_sequences: None, metadata: None, - provider_options: None, + provider_options: controls + .openrouter + .as_ref() + .map(|or| serde_json::json!({ "openrouter": or.to_request_json() })), }; let inference_start = Instant::now(); @@ -1086,7 +1167,7 @@ impl CodergenBackend for AgentApiBackend { emitter, stage_scope, &request, - controls, + &controls, fallback_chain, ) .await; @@ -2614,6 +2695,99 @@ reasoning = false assert_eq!(controls.reasoning_effort, None); } + #[test] + fn openrouter_attrs_absent_yields_none() { + let backend = AgentApiBackend::new_from_env( + "anthropic/claude-sonnet-4-6".to_string(), + ProviderId::new("openrouter"), + Vec::new(), + SteeringHub::for_tests(), + ); + let node = Node::new("work"); + + let controls = backend.resolve_effective_request_controls(&node).unwrap(); + + assert!(controls.openrouter.is_none()); + } + + #[test] + fn openrouter_attrs_parse_into_provider_options() { + let backend = AgentApiBackend::new_from_env( + "anthropic/claude-sonnet-4-6".to_string(), + ProviderId::new("openrouter"), + Vec::new(), + SteeringHub::for_tests(), + ); + let mut node = Node::new("work"); + node.attrs.insert( + "openrouter_provider_sort".to_string(), + fabro_graphviz::graph::AttrValue::String("throughput".to_string()), + ); + node.attrs.insert( + "openrouter_fallback_models".to_string(), + fabro_graphviz::graph::AttrValue::String( + "openai/gpt-5.5, google/gemini-3.1-pro-preview".to_string(), + ), + ); + node.attrs.insert( + "openrouter_transforms".to_string(), + fabro_graphviz::graph::AttrValue::String("middle-out".to_string()), + ); + node.attrs.insert( + "openrouter_allow_fallbacks".to_string(), + fabro_graphviz::graph::AttrValue::String("false".to_string()), + ); + node.attrs.insert( + "openrouter_data_collection".to_string(), + fabro_graphviz::graph::AttrValue::String("deny".to_string()), + ); + + let controls = backend.resolve_effective_request_controls(&node).unwrap(); + let or = controls + .openrouter + .as_ref() + .expect("openrouter options should be populated"); + + let provider_options = serde_json::json!({ "openrouter": or.to_request_json() }); + assert_eq!( + provider_options, + serde_json::json!({ + "openrouter": { + "provider": { + "sort": "throughput", + "allow_fallbacks": false, + "data_collection": "deny", + }, + "models": ["openai/gpt-5.5", "google/gemini-3.1-pro-preview"], + "transforms": ["middle-out"], + } + }) + ); + } + + #[test] + fn openrouter_provider_sort_invalid_value_errors() { + let backend = AgentApiBackend::new_from_env( + "anthropic/claude-sonnet-4-6".to_string(), + ProviderId::new("openrouter"), + Vec::new(), + SteeringHub::for_tests(), + ); + let mut node = Node::new("work"); + node.attrs.insert( + "openrouter_provider_sort".to_string(), + fabro_graphviz::graph::AttrValue::String("cheapest".to_string()), + ); + + let err = backend + .resolve_effective_request_controls(&node) + .expect_err("invalid sort value should error"); + assert!( + err.to_string().contains("openrouter_provider_sort"), + "error should mention the offending attr: {err}", + ); + } + #[tokio::test] async fn api_backend_uses_source_credentials() { let dir = tempfile::tempdir().unwrap(); diff --git a/lib/crates/fabro-workflow/src/handler/llm/router.rs b/lib/crates/fabro-workflow/src/handler/llm/router.rs index 24dc97e0e..69f8839a5 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/router.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/router.rs @@ -182,6 +182,7 @@ mod tests { Ok(EffectiveRequestControls { reasoning_effort: Some(ReasoningEffort::High), speed: Some(Speed::Fast), + openrouter: None, }) } } diff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/crates/fabro-workflow/src/handler/prompt.rs index 1c2a82267..aada204de 100644 --- a/lib/crates/fabro-workflow/src/handler/prompt.rs +++ b/lib/crates/fabro-workflow/src/handler/prompt.rs @@ -361,6 +361,7 @@ mod tests { Ok(crate::handler::llm::api::EffectiveRequestControls { reasoning_effort: Some(ReasoningEffort::High), speed: Some(Speed::Fast), + openrouter: None, }) } } @@ -555,6 +556,7 @@ mod tests { Ok(crate::handler::llm::api::EffectiveRequestControls { reasoning_effort: Some(ReasoningEffort::High), speed: Some(Speed::Fast), + openrouter: None, }) } }