feat(catalog): add OpenRouter as opt-in built-in provider

Ship a curated 17-model OpenRouter catalog that's disabled by
default. Users opt in with a single section in settings.toml:

    [llm.providers.openrouter]
    enabled = true

The provider reuses the existing OpenAiCompatibleAdapter via the
catalog's adapter = "openai_compatible" setting, with attribution
headers (HTTP-Referer, X-Title) injected through extra_headers.
Model IDs follow OpenRouter's vendor-namespaced slug convention
(anthropic/claude-sonnet-4-6, deepseek/deepseek-v4-flash, etc.)
so they don't collide with direct-provider entries.

OPENROUTER_API_KEY is registered as an optional vault secret across
the env var registry and secret classification, matching the
treatment of other LLM provider keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-05-27 13:10:58 -04:00
parent e2d1d7203c
commit f326b9421a
4 changed files with 399 additions and 0 deletions

View file

@ -1817,6 +1817,40 @@ enabled = true
assert!(catalog.default_for_provider(&ollama).is_none());
}
#[test]
fn builtin_openrouter_provider_is_opt_in() {
let openrouter = ProviderId::new("openrouter");
let builtin = Catalog::builtin();
assert!(builtin.provider(&openrouter).is_none());
assert!(builtin.list(Some(&openrouter)).is_empty());
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 provider = catalog
.provider(&openrouter)
.expect("enabled OpenRouter provider should be present");
assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible);
assert_eq!(
provider.base_url.as_deref(),
Some("https://openrouter.ai/api/v1")
);
let models = catalog.list(Some(&openrouter));
assert_eq!(models.len(), 17);
let default = catalog
.default_for_provider(&openrouter)
.expect("openrouter catalog should have a default model");
assert_eq!(default.id, "anthropic/claude-sonnet-4-6");
}
#[test]
fn builtin_get_by_id() {
let m = Catalog::builtin().get("claude-opus-4-6").unwrap();

View file

@ -0,0 +1,361 @@
[providers.openrouter]
display_name = "OpenRouter"
adapter = "openai_compatible"
api_key_url = "https://openrouter.ai/keys"
base_url = "https://openrouter.ai/api/v1"
priority = 25
enabled = false
[providers.openrouter.auth]
credentials = ["env:OPENROUTER_API_KEY", "vault:OPENROUTER_API_KEY"]
[providers.openrouter.extra_headers]
"HTTP-Referer" = { literal = "https://fabro.sh" }
"X-Title" = { literal = "Fabro" }
# To enable OpenRouter, add the following to ~/.fabro/settings.toml:
#
# [llm.providers.openrouter]
# enabled = true
#
# Then run `fabro provider login openrouter` to store the API key,
# or set the OPENROUTER_API_KEY environment variable.
# ---------- Anthropic via OpenRouter ----------
[models."anthropic/claude-opus-4-7"]
provider = "openrouter"
api_id = "anthropic/claude-opus-4.7"
display_name = "Claude Opus 4.7 (via OpenRouter)"
family = "or-claude-4"
[models."anthropic/claude-opus-4-7".limits]
context_window = 1000000
max_output = 128000
[models."anthropic/claude-opus-4-7".features]
tools = true
vision = true
reasoning = true
prompt_cache = true
[models."anthropic/claude-opus-4-7".costs]
input_cost_per_mtok = 5.0
output_cost_per_mtok = 25.0
cache_input_cost_per_mtok = 0.5
[models."anthropic/claude-sonnet-4-6"]
provider = "openrouter"
api_id = "anthropic/claude-sonnet-4.6"
display_name = "Claude Sonnet 4.6 (via OpenRouter)"
family = "or-claude-4"
default = true
[models."anthropic/claude-sonnet-4-6".limits]
context_window = 1000000
max_output = 64000
[models."anthropic/claude-sonnet-4-6".features]
tools = true
vision = true
reasoning = true
prompt_cache = true
[models."anthropic/claude-sonnet-4-6".costs]
input_cost_per_mtok = 3.0
output_cost_per_mtok = 15.0
cache_input_cost_per_mtok = 0.3
[models."anthropic/claude-haiku-4-5"]
provider = "openrouter"
api_id = "anthropic/claude-haiku-4.5"
display_name = "Claude Haiku 4.5 (via OpenRouter)"
family = "or-claude-4"
small_default = true
[models."anthropic/claude-haiku-4-5".limits]
context_window = 200000
max_output = 8192
[models."anthropic/claude-haiku-4-5".features]
tools = true
vision = true
reasoning = false
prompt_cache = true
[models."anthropic/claude-haiku-4-5".costs]
input_cost_per_mtok = 1.0
output_cost_per_mtok = 5.0
cache_input_cost_per_mtok = 0.1
# ---------- OpenAI via OpenRouter ----------
[models."openai/gpt-5.4"]
provider = "openrouter"
api_id = "openai/gpt-5.4"
display_name = "GPT-5.4 (via OpenRouter)"
family = "or-gpt-5"
[models."openai/gpt-5.4".limits]
context_window = 1050000
max_output = 32768
[models."openai/gpt-5.4".features]
tools = true
vision = true
reasoning = true
[models."openai/gpt-5.4".costs]
input_cost_per_mtok = 2.5
output_cost_per_mtok = 15.0
[models."openai/gpt-5.5"]
provider = "openrouter"
api_id = "openai/gpt-5.5"
display_name = "GPT-5.5 (via OpenRouter)"
family = "or-gpt-5"
[models."openai/gpt-5.5".limits]
context_window = 1050000
max_output = 32768
[models."openai/gpt-5.5".features]
tools = true
vision = true
reasoning = true
[models."openai/gpt-5.5".costs]
input_cost_per_mtok = 5.0
output_cost_per_mtok = 30.0
# ---------- Google Gemini via OpenRouter ----------
[models."google/gemini-3.1-pro-preview"]
provider = "openrouter"
api_id = "google/gemini-3.1-pro-preview"
display_name = "Gemini 3.1 Pro Preview (via OpenRouter)"
family = "or-gemini-3"
[models."google/gemini-3.1-pro-preview".limits]
context_window = 1048576
max_output = 65536
[models."google/gemini-3.1-pro-preview".features]
tools = true
vision = true
reasoning = true
[models."google/gemini-3.1-pro-preview".costs]
input_cost_per_mtok = 2.0
output_cost_per_mtok = 12.0
[models."google/gemini-3.5-flash"]
provider = "openrouter"
api_id = "google/gemini-3.5-flash"
display_name = "Gemini 3.5 Flash (via OpenRouter)"
family = "or-gemini-3"
[models."google/gemini-3.5-flash".limits]
context_window = 1048576
max_output = 65536
[models."google/gemini-3.5-flash".features]
tools = true
vision = true
reasoning = false
[models."google/gemini-3.5-flash".costs]
input_cost_per_mtok = 1.5
output_cost_per_mtok = 9.0
# ---------- Open-weights models ----------
[models."xiaomi/mimo-v2.5-pro"]
provider = "openrouter"
api_id = "xiaomi/mimo-v2.5-pro"
display_name = "Xiaomi MiMo v2.5 Pro"
family = "or-mimo"
[models."xiaomi/mimo-v2.5-pro".limits]
context_window = 1050000
max_output = 16384
[models."xiaomi/mimo-v2.5-pro".features]
tools = true
vision = false
reasoning = false
[models."xiaomi/mimo-v2.5-pro".costs]
input_cost_per_mtok = 0.435
output_cost_per_mtok = 0.87
[models."minimax/minimax-m2.7"]
provider = "openrouter"
api_id = "minimax/minimax-m2.7"
display_name = "MiniMax M2.7"
family = "or-minimax"
[models."minimax/minimax-m2.7".limits]
context_window = 200000
max_output = 16384
[models."minimax/minimax-m2.7".features]
tools = true
vision = false
reasoning = false
[models."minimax/minimax-m2.7".costs]
input_cost_per_mtok = 0.28
output_cost_per_mtok = 1.20
[models."deepseek/deepseek-v4-pro"]
provider = "openrouter"
api_id = "deepseek/deepseek-v4-pro"
display_name = "DeepSeek V4 Pro"
family = "or-deepseek"
[models."deepseek/deepseek-v4-pro".limits]
context_window = 1050000
max_output = 16384
[models."deepseek/deepseek-v4-pro".features]
tools = true
vision = false
reasoning = true
[models."deepseek/deepseek-v4-pro".costs]
input_cost_per_mtok = 0.435
output_cost_per_mtok = 0.87
[models."deepseek/deepseek-v4-flash"]
provider = "openrouter"
api_id = "deepseek/deepseek-v4-flash"
display_name = "DeepSeek V4 Flash"
family = "or-deepseek"
[models."deepseek/deepseek-v4-flash".limits]
context_window = 1050000
max_output = 16384
[models."deepseek/deepseek-v4-flash".features]
tools = true
vision = false
reasoning = false
[models."deepseek/deepseek-v4-flash".costs]
input_cost_per_mtok = 0.10
output_cost_per_mtok = 0.20
[models."moonshotai/kimi-k2.6"]
provider = "openrouter"
api_id = "moonshotai/kimi-k2.6"
display_name = "Kimi K2.6"
family = "or-kimi"
[models."moonshotai/kimi-k2.6".limits]
context_window = 262144
max_output = 16384
[models."moonshotai/kimi-k2.6".features]
tools = true
vision = false
reasoning = false
[models."moonshotai/kimi-k2.6".costs]
input_cost_per_mtok = 0.73
output_cost_per_mtok = 3.49
[models."qwen/qwen3-coder"]
provider = "openrouter"
api_id = "qwen/qwen3-coder"
display_name = "Qwen3 Coder"
family = "or-qwen-3"
[models."qwen/qwen3-coder".limits]
context_window = 1050000
max_output = 16384
[models."qwen/qwen3-coder".features]
tools = true
vision = false
reasoning = false
[models."qwen/qwen3-coder".costs]
input_cost_per_mtok = 0.22
output_cost_per_mtok = 1.80
[models."qwen/qwen3.6-flash"]
provider = "openrouter"
api_id = "qwen/qwen3.6-flash"
display_name = "Qwen3.6 Flash"
family = "or-qwen-3"
[models."qwen/qwen3.6-flash".limits]
context_window = 1000000
max_output = 16384
[models."qwen/qwen3.6-flash".features]
tools = true
vision = false
reasoning = false
[models."qwen/qwen3.6-flash".costs]
input_cost_per_mtok = 0.1875
output_cost_per_mtok = 1.125
[models."z-ai/glm-4.6"]
provider = "openrouter"
api_id = "z-ai/glm-4.6"
display_name = "GLM 4.6"
family = "or-glm"
[models."z-ai/glm-4.6".limits]
context_window = 203000
max_output = 16384
[models."z-ai/glm-4.6".features]
tools = true
vision = false
reasoning = false
[models."z-ai/glm-4.6".costs]
input_cost_per_mtok = 0.43
output_cost_per_mtok = 1.74
[models."nvidia/nemotron-3-super-120b-a12b"]
provider = "openrouter"
api_id = "nvidia/nemotron-3-super-120b-a12b"
display_name = "NVIDIA Nemotron 3 Super 120B"
family = "or-nemotron"
[models."nvidia/nemotron-3-super-120b-a12b".limits]
context_window = 1000000
max_output = 16384
[models."nvidia/nemotron-3-super-120b-a12b".features]
tools = true
vision = false
reasoning = false
[models."nvidia/nemotron-3-super-120b-a12b".costs]
input_cost_per_mtok = 0.09
output_cost_per_mtok = 0.45
[models."mistralai/devstral-2512"]
provider = "openrouter"
api_id = "mistralai/devstral-2512"
display_name = "Devstral 2512"
family = "or-devstral"
[models."mistralai/devstral-2512".limits]
context_window = 262144
max_output = 16384
[models."mistralai/devstral-2512".features]
tools = true
vision = false
reasoning = false
[models."mistralai/devstral-2512".costs]
input_cost_per_mtok = 0.40
output_cost_per_mtok = 2.00

View file

@ -56,6 +56,7 @@ impl EnvVars {
pub const OPENAI_PROJECT: &'static str = "OPENAI_PROJECT";
pub const OPENAI_ORG_ID: &'static str = "OPENAI_ORG_ID";
pub const OPENAI_PROJECT_ID: &'static str = "OPENAI_PROJECT_ID";
pub const OPENROUTER_API_KEY: &'static str = "OPENROUTER_API_KEY";
pub const ZAI_API_KEY: &'static str = "ZAI_API_KEY";
// GitHub, OAuth, and Slack
@ -193,6 +194,7 @@ mod tests {
EnvVars::OPENAI_PROJECT,
EnvVars::OPENAI_ORG_ID,
EnvVars::OPENAI_PROJECT_ID,
EnvVars::OPENROUTER_API_KEY,
EnvVars::ZAI_API_KEY,
EnvVars::GH_TOKEN,
EnvVars::GITHUB_APP_CLIENT_SECRET,

View file

@ -28,6 +28,7 @@ const OPTIONAL_VAULT_SECRETS: &[&str] = &[
EnvVars::KIMI_API_KEY,
EnvVars::MINIMAX_API_KEY,
EnvVars::OPENAI_API_KEY,
EnvVars::OPENROUTER_API_KEY,
EnvVars::ZAI_API_KEY,
EnvVars::DAYTONA_API_KEY,
];
@ -90,6 +91,7 @@ mod tests {
EnvVars::KIMI_API_KEY,
EnvVars::MINIMAX_API_KEY,
EnvVars::OPENAI_API_KEY,
EnvVars::OPENROUTER_API_KEY,
EnvVars::ZAI_API_KEY,
] {
assert_eq!(