From af99acc811311b49cc7c4e307caa3cd09347107f Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Thu, 28 May 2026 15:29:21 -0400 Subject: [PATCH] feat(llm): add dedicated OpenRouter adapter on the openai_chat module Introduces AdapterKind::OpenRouter and a new providers/openrouter.rs adapter that sits on top of the shared openai_chat module via ChatHooks: - mutate_request: translates the typed Request.reasoning_effort field into OpenRouter's {reasoning: {effort: ...}} JSON shape, unless the caller has already set a reasoning value explicitly via provider_options.openrouter. - enrich_response: pulls OpenRouter's inline usage.cost into Response.cost_usd with CostSource::Authoritative, overriding any catalog-estimated value. The catalog's adapter_defaults table now recognizes OpenRouter (it shares the OpenAI billing policy and agent profile at the wire level) and the existing builtin_openrouter_provider_is_opt_in test is updated to assert AdapterKind::OpenRouter. A new family-allowlist test asserts the per-vendor family rename that lands in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-llm/src/adapter_registry.rs | 55 ++++ lib/crates/fabro-llm/src/providers/mod.rs | 2 + .../fabro-llm/src/providers/openrouter.rs | 290 ++++++++++++++++++ lib/crates/fabro-model/src/adapter.rs | 3 + lib/crates/fabro-model/src/catalog.rs | 56 +++- 5 files changed, 401 insertions(+), 5 deletions(-) create mode 100644 lib/crates/fabro-llm/src/providers/openrouter.rs diff --git a/lib/crates/fabro-llm/src/adapter_registry.rs b/lib/crates/fabro-llm/src/adapter_registry.rs index a778a116b..b02854e26 100644 --- a/lib/crates/fabro-llm/src/adapter_registry.rs +++ b/lib/crates/fabro-llm/src/adapter_registry.rs @@ -185,6 +185,32 @@ fn build_openai_compatible(config: AdapterConfig) -> Result Result { + let base_url = config.base_url.ok_or_else(|| Error::Configuration { + message: format!( + "provider '{}' uses openrouter adapter but does not configure base_url", + config.provider_id + ), + source: None, + })?; + let api_key = apply_primary_auth_header(config.auth_header.take(), &mut config.extra_headers); + let mut adapter = providers::OpenRouterAdapter::new_optional_auth(api_key, base_url) + .with_name(config.provider_id); + if !config.extra_headers.is_empty() { + adapter = adapter.with_default_headers(config.extra_headers); + } + if let Some(catalog) = config.catalog { + adapter = adapter.with_catalog(catalog); + } + Ok(adapter) +} + +fn build_openrouter(config: AdapterConfig) -> Result, Error> { + Ok(Arc::new(build_openrouter_adapter(config)?)) +} + /// Return the factory for a known adapter kind. #[must_use] pub fn factory_for(adapter_kind: AdapterKind) -> AdapterFactory { @@ -193,6 +219,7 @@ pub fn factory_for(adapter_kind: AdapterKind) -> AdapterFactory { AdapterKind::OpenAi => build_openai, AdapterKind::Gemini => build_gemini, AdapterKind::OpenAiCompatible => build_openai_compatible, + AdapterKind::OpenRouter => build_openrouter, } } @@ -341,4 +368,32 @@ mod tests { .contains("uses openai_compatible adapter but does not configure base_url") ); } + + #[test] + fn openrouter_factory_uses_provider_id_for_name() { + let config = AdapterConfig { + provider_id: "openrouter".to_string(), + auth_header: Some(ApiKeyHeader::Bearer("k".to_string())), + base_url: Some("https://openrouter.ai/api/v1".to_string()), + extra_headers: HashMap::new(), + codex_mode: false, + org_id: None, + project_id: None, + catalog: None, + }; + let adapter = factory_for(AdapterKind::OpenRouter)(config).unwrap(); + assert_eq!(adapter.name(), "openrouter"); + } + + #[test] + fn openrouter_factory_errors_without_base_url() { + let config = AdapterConfig::new("openrouter", ApiKeyHeader::Bearer("k".to_string())); + let Err(err) = factory_for(AdapterKind::OpenRouter)(config) else { + panic!("expected missing base_url error"); + }; + assert!( + err.to_string() + .contains("uses openrouter adapter but does not configure base_url") + ); + } } diff --git a/lib/crates/fabro-llm/src/providers/mod.rs b/lib/crates/fabro-llm/src/providers/mod.rs index e9def7548..18c71ae36 100644 --- a/lib/crates/fabro-llm/src/providers/mod.rs +++ b/lib/crates/fabro-llm/src/providers/mod.rs @@ -6,9 +6,11 @@ pub mod http_api; pub mod openai; pub(crate) mod openai_chat; pub mod openai_compatible; +pub mod openrouter; pub use anthropic::Adapter as AnthropicAdapter; pub use fabro_server::Adapter as FabroServerAdapter; pub use gemini::Adapter as GeminiAdapter; pub use openai::Adapter as OpenAiAdapter; pub use openai_compatible::Adapter as OpenAiCompatibleAdapter; +pub use openrouter::Adapter as OpenRouterAdapter; diff --git a/lib/crates/fabro-llm/src/providers/openrouter.rs b/lib/crates/fabro-llm/src/providers/openrouter.rs new file mode 100644 index 000000000..4d4a79028 --- /dev/null +++ b/lib/crates/fabro-llm/src/providers/openrouter.rs @@ -0,0 +1,290 @@ +//! OpenRouter adapter (`OpenAI`-Chat-Completions compatible with extensions). +//! +//! `OpenRouter` () is a gateway over many model +//! providers that speaks the `OpenAI` Chat Completions wire protocol but +//! adds extra top-level request fields (`provider`, `models`, +//! `transforms`, `plugins`, `reasoning`) and an inline authoritative +//! `usage.cost` in responses. This adapter sits on top of the shared +//! [`super::openai_chat`] module, using +//! [`ChatHooks`](super::openai_chat::ChatHooks) to layer OpenRouter-specific +//! behavior on top of the generic wire layer. +//! +//! - `mutate_request`: translates typed `Request.reasoning_effort` into OR's +//! `{reasoning: {effort: ...}}` JSON shape. +//! - `enrich_response`: pulls `usage.cost` out of the raw response into +//! `Response.cost_usd` with `CostSource::Authoritative`. +//! +//! Typed OpenRouter routing controls (`openrouter_provider_sort`, etc.) +//! flow in through `Request.provider_options["openrouter"]` — the +//! existing `merge_provider_options` path in `openai_chat::request` +//! handles pasting them into the request body. + +use std::sync::Arc; + +use fabro_model::Catalog; + +use crate::error::Error; +use crate::provider::{ + ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice, +}; +use crate::providers::openai_chat::{self, ChatHooks}; +use crate::types::{AdapterTimeout, CostSource, Request, Response}; + +/// `OpenRouter` adapter. Speaks the Chat Completions wire protocol with +/// OpenRouter-specific request/response translations applied via +/// [`ChatHooks`]. +pub struct Adapter { + pub(crate) http: super::http_api::HttpApi, + provider_name: String, + catalog: Option>, +} + +impl Adapter { + #[must_use] + pub fn new(api_key: impl Into, base_url: impl Into) -> Self { + Self::new_optional_auth(Some(api_key.into()), base_url) + } + + #[must_use] + pub fn new_optional_auth(api_key: Option, base_url: impl Into) -> Self { + Self { + http: super::http_api::HttpApi::new_optional(api_key, base_url), + provider_name: "openrouter".to_string(), + catalog: None, + } + } + + #[must_use] + pub fn with_name(mut self, name: impl Into) -> Self { + self.provider_name = name.into(); + self + } + + #[must_use] + pub fn with_default_headers(self, headers: std::collections::HashMap) -> Self { + Self { + http: self.http.with_default_headers(headers), + ..self + } + } + + #[must_use] + pub fn with_catalog(mut self, catalog: Arc) -> Self { + self.catalog = Some(catalog); + self + } + + #[must_use] + pub fn with_timeout(self, timeout: AdapterTimeout) -> Self { + Self { + http: self.http.with_timeout(timeout), + ..self + } + } + + /// Build a `fabro_http::RequestBuilder` with default headers and auth. + fn build_request(&self, url: &str) -> fabro_http::RequestBuilder { + let mut req = self.http.client.post(url); + // Apply default_headers first so adapter-specific headers can override. + for (key, value) in &self.http.default_headers { + req = req.header(key, value); + } + if let Some(api_key) = &self.http.api_key { + req = req.bearer_auth(api_key); + } + req + } +} + +#[async_trait::async_trait] +impl ProviderAdapter for Adapter { + fn name(&self) -> &str { + &self.provider_name + } + + fn validate_request(&self, request: &Request) -> Result<(), Error> { + validate_standard_speed(self, request)?; + if let Some(tc) = &request.tool_choice { + validate_tool_choice(self, tc)?; + } + Ok(()) + } + + async fn complete(&self, request: &Request) -> Result { + self.validate_request(request)?; + openai_chat::complete( + &self.http, + |url| self.build_request(url), + self.catalog.as_deref(), + &self.provider_name, + request, + openrouter_hooks(), + ) + .await + } + + async fn stream(&self, request: &Request) -> Result { + self.validate_request(request)?; + openai_chat::stream( + &self.http, + |url| self.build_request(url), + self.catalog.clone(), + &self.provider_name, + request, + openrouter_hooks(), + ) + .await + } +} + +fn openrouter_hooks() -> ChatHooks { + ChatHooks { + mutate_request: Some(or_mutate_request), + enrich_response: Some(or_enrich_response), + } +} + +/// Translate the typed `Request.reasoning_effort` field into OR's +/// `{reasoning: {effort: "..."}}` shape, unless the user has already +/// set `reasoning` explicitly via `provider_options.openrouter`. +fn or_mutate_request(body: &mut serde_json::Value, request: &Request) { + if body.get("reasoning").is_some() { + return; + } + if let Some(effort) = request.reasoning_effort { + body["reasoning"] = serde_json::json!({ + "effort": <&'static str>::from(effort), + }); + } +} + +/// Pull OpenRouter's inline `usage.cost` (USD) into `Response.cost_usd` +/// with `CostSource::Authoritative`. Overrides any catalog-estimated +/// cost. +fn or_enrich_response(response: &mut Response, raw: &serde_json::Value) { + if let Some(cost) = raw + .get("usage") + .and_then(|u| u.get("cost")) + .and_then(serde_json::Value::as_f64) + { + response.cost_usd = Some(cost); + response.cost_source = Some(CostSource::Authoritative); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{FinishReason, Message, ReasoningEffort, TokenCounts}; + + fn minimal_request() -> Request { + Request { + model: "anthropic/claude-sonnet-4-6".to_string(), + messages: vec![Message::user("Hello")], + provider: None, + tools: None, + tool_choice: None, + response_format: None, + temperature: None, + top_p: None, + max_tokens: None, + stop_sequences: None, + reasoning_effort: None, + speed: None, + metadata: None, + provider_options: None, + } + } + + fn empty_response() -> Response { + Response { + id: "resp_1".into(), + model: "anthropic/claude-sonnet-4-6".into(), + provider: "openrouter".into(), + message: Message::assistant(""), + finish_reason: FinishReason::Stop, + usage: TokenCounts::default(), + cost_usd: None, + cost_source: None, + raw: None, + warnings: vec![], + rate_limit: None, + } + } + + #[test] + fn or_mutate_request_translates_typed_reasoning_effort() { + let mut request = minimal_request(); + request.reasoning_effort = Some(ReasoningEffort::High); + let mut body = serde_json::json!({ "model": "x", "messages": [] }); + or_mutate_request(&mut body, &request); + assert_eq!(body["reasoning"], serde_json::json!({ "effort": "high" })); + } + + #[test] + fn or_mutate_request_preserves_explicit_provider_options_reasoning() { + let mut request = minimal_request(); + request.reasoning_effort = Some(ReasoningEffort::High); + // Body already carries an explicit `reasoning` from + // provider_options merging. + let mut body = serde_json::json!({ + "model": "x", + "messages": [], + "reasoning": { "max_tokens": 1024 }, + }); + or_mutate_request(&mut body, &request); + assert_eq!( + body["reasoning"], + serde_json::json!({ "max_tokens": 1024 }), + "explicit reasoning should not be clobbered by typed effort translation", + ); + } + + #[test] + fn or_mutate_request_no_op_without_reasoning_effort() { + let request = minimal_request(); + let mut body = serde_json::json!({ "model": "x", "messages": [] }); + let snapshot = body.clone(); + or_mutate_request(&mut body, &request); + assert_eq!(body, snapshot); + } + + #[test] + fn or_enrich_response_extracts_usage_cost() { + let mut response = empty_response(); + let raw = serde_json::json!({ + "id": "resp_1", + "usage": { "prompt_tokens": 10, "completion_tokens": 20, "cost": 0.0042 } + }); + or_enrich_response(&mut response, &raw); + assert_eq!(response.cost_usd, Some(0.0042)); + assert_eq!(response.cost_source, Some(CostSource::Authoritative)); + } + + #[test] + fn or_enrich_response_overrides_estimated_cost() { + let mut response = empty_response(); + response.cost_usd = Some(0.01); + response.cost_source = Some(CostSource::Estimated); + let raw = serde_json::json!({ + "usage": { "prompt_tokens": 10, "completion_tokens": 20, "cost": 0.005 } + }); + or_enrich_response(&mut response, &raw); + assert_eq!(response.cost_usd, Some(0.005)); + assert_eq!(response.cost_source, Some(CostSource::Authoritative)); + } + + #[test] + fn or_enrich_response_no_op_without_usage_cost() { + let mut response = empty_response(); + response.cost_usd = Some(0.01); + response.cost_source = Some(CostSource::Estimated); + let raw = serde_json::json!({ + "usage": { "prompt_tokens": 10, "completion_tokens": 20 } + }); + or_enrich_response(&mut response, &raw); + // Unchanged: enrich is a no-op when usage.cost is absent. + assert_eq!(response.cost_usd, Some(0.01)); + assert_eq!(response.cost_source, Some(CostSource::Estimated)); + } +} diff --git a/lib/crates/fabro-model/src/adapter.rs b/lib/crates/fabro-model/src/adapter.rs index baa74eb7e..12d971aa6 100644 --- a/lib/crates/fabro-model/src/adapter.rs +++ b/lib/crates/fabro-model/src/adapter.rs @@ -32,6 +32,9 @@ pub enum AdapterKind { #[serde(rename = "openai_compatible")] #[strum(to_string = "openai_compatible")] OpenAiCompatible, + #[serde(rename = "openrouter")] + #[strum(to_string = "openrouter")] + OpenRouter, } impl AdapterKind { diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index 755a8b6c2..1a5798496 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -1285,10 +1285,12 @@ fn adapter_defaults(adapter: AdapterKind) -> AdapterDefaults { agent_profile: AgentProfileKind::Anthropic, billing_policy: BillingPolicy::Anthropic, }, - AdapterKind::OpenAi | AdapterKind::OpenAiCompatible => AdapterDefaults { - agent_profile: AgentProfileKind::OpenAi, - billing_policy: BillingPolicy::OpenAi, - }, + AdapterKind::OpenAi | AdapterKind::OpenAiCompatible | AdapterKind::OpenRouter => { + AdapterDefaults { + agent_profile: AgentProfileKind::OpenAi, + billing_policy: BillingPolicy::OpenAi, + } + } AdapterKind::Gemini => AdapterDefaults { agent_profile: AgentProfileKind::Gemini, billing_policy: BillingPolicy::Gemini, @@ -1836,7 +1838,7 @@ enabled = true let provider = catalog .provider(&openrouter) .expect("enabled OpenRouter provider should be present"); - assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); + assert_eq!(provider.adapter, AdapterKind::OpenRouter); assert_eq!( provider.base_url.as_deref(), Some("https://openrouter.ai/api/v1") @@ -1851,6 +1853,50 @@ enabled = true assert_eq!(default.id, "anthropic/claude-sonnet-4-6"); } + #[test] + fn openrouter_models_use_per_vendor_family_names() { + let openrouter = ProviderId::new("openrouter"); + let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( + r" +[providers.openrouter] +enabled = true +", + )) + .expect("enabled OpenRouter override should build from the built-in provider settings"); + + let allowed_families: &[&str] = &[ + "claude-4", + "gpt-5", + "gemini-3", + "mimo-v2", + "minimax-m2", + "deepseek-v4", + "kimi-k2", + "qwen3", + "glm-4", + "nemotron-3", + "devstral", + ]; + + let models = catalog.list(Some(&openrouter)); + assert_eq!(models.len(), 17); + for model in &models { + assert_ne!( + model.family.as_str(), + "openrouter", + "model {} still has family = openrouter", + model.id, + ); + assert!( + allowed_families.contains(&model.family.as_str()), + "model {} has unexpected family {:?}; expected one of {:?}", + model.id, + model.family, + allowed_families, + ); + } + } + #[test] fn builtin_get_by_id() { let m = Catalog::builtin().get("claude-opus-4-6").unwrap();