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) <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-05-28 15:29:21 -04:00
parent 480ff56aba
commit af99acc811
5 changed files with 401 additions and 5 deletions

View file

@ -185,6 +185,32 @@ fn build_openai_compatible(config: AdapterConfig) -> Result<Arc<dyn ProviderAdap
Ok(Arc::new(build_openai_compatible_adapter(config)?))
}
fn build_openrouter_adapter(
mut config: AdapterConfig,
) -> Result<providers::OpenRouterAdapter, Error> {
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<Arc<dyn ProviderAdapter>, 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")
);
}
}

View file

@ -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;

View file

@ -0,0 +1,290 @@
//! OpenRouter adapter (`OpenAI`-Chat-Completions compatible with extensions).
//!
//! `OpenRouter` (<https://openrouter.ai>) 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<Arc<Catalog>>,
}
impl Adapter {
#[must_use]
pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
Self::new_optional_auth(Some(api_key.into()), base_url)
}
#[must_use]
pub fn new_optional_auth(api_key: Option<String>, base_url: impl Into<String>) -> 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<String>) -> Self {
self.provider_name = name.into();
self
}
#[must_use]
pub fn with_default_headers(self, headers: std::collections::HashMap<String, String>) -> Self {
Self {
http: self.http.with_default_headers(headers),
..self
}
}
#[must_use]
pub fn with_catalog(mut self, catalog: Arc<Catalog>) -> 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<Response, Error> {
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<StreamEventStream, Error> {
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));
}
}

View file

@ -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 {

View file

@ -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();