diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index 20ac495af..755a8b6c2 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -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(); diff --git a/lib/crates/fabro-model/src/catalog/providers/openrouter.toml b/lib/crates/fabro-model/src/catalog/providers/openrouter.toml new file mode 100644 index 000000000..e9c9ccae5 --- /dev/null +++ b/lib/crates/fabro-model/src/catalog/providers/openrouter.toml @@ -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 diff --git a/lib/crates/fabro-static/src/env_vars.rs b/lib/crates/fabro-static/src/env_vars.rs index eec275f72..315de6477 100644 --- a/lib/crates/fabro-static/src/env_vars.rs +++ b/lib/crates/fabro-static/src/env_vars.rs @@ -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, diff --git a/lib/crates/fabro-static/src/secret_registry.rs b/lib/crates/fabro-static/src/secret_registry.rs index 8534636bd..b6770aeab 100644 --- a/lib/crates/fabro-static/src/secret_registry.rs +++ b/lib/crates/fabro-static/src/secret_registry.rs @@ -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!(