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<String> fields don't qualify);
complete_one_shot_request now takes a reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-05-28 15:30:22 -04:00
parent fe0af4d530
commit f72f3fb972
4 changed files with 366 additions and 6 deletions

View file

@ -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<OpenRouterProviderSort>,
pub allow_fallbacks: Option<bool>,
pub data_collection: Option<OpenRouterDataCollection>,
pub models: Vec<String>,
pub transforms: Vec<String>,
}
/// 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"],
})
);
}
}

View file

@ -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<ReasoningEffort>,
pub(crate) speed: Option<Speed>,
/// OpenRouter-specific options sourced from typed `openrouter_*` stage
/// attrs. `None` when no `openrouter_*` attr is set on the node.
pub(crate) openrouter: Option<OpenRouterOptions>,
}
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<Option<OpenRouterOptions>, 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::<OpenRouterProviderSort>().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::<OpenRouterDataCollection>().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::<bool>().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<String> {
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<Emitter>,
stage_scope: &StageScope,
request: &Request,
controls: EffectiveRequestControls,
controls: &EffectiveRequestControls,
fallback_chain: &[FallbackTarget],
) -> Result<OneShotCompletion, Error> {
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();

View file

@ -182,6 +182,7 @@ mod tests {
Ok(EffectiveRequestControls {
reasoning_effort: Some(ReasoningEffort::High),
speed: Some(Speed::Fast),
openrouter: None,
})
}
}

View file

@ -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,
})
}
}