mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-29 01:42:21 +00:00
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:
parent
480ff56aba
commit
af99acc811
5 changed files with 401 additions and 5 deletions
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
290
lib/crates/fabro-llm/src/providers/openrouter.rs
Normal file
290
lib/crates/fabro-llm/src/providers/openrouter.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue