use std::sync::Arc;
use super::{
Env, LlmProvider, Reasoning,
gemini::{GEMINI_BASE_URL, GEMINI_DEFAULT_MODEL, GeminiConfig, GeminiProvider},
openai::{OPENAI_BASE_URL, OPENROUTER_DEFAULT_MODEL, OpenAiConfig, OpenAiProvider},
};
#[derive(Clone, Default, PartialEq, Eq)]
pub struct LlmSettings {
pub provider: String,
pub api_key: Option<String>,
pub model: Option<String>,
pub base_url: Option<String>,
pub http_referer: Option<String>,
pub x_title: Option<String>,
pub reasoning: Option<Reasoning>,
pub stream_usage: Option<bool>,
}
impl std::fmt::Debug for LlmSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LlmSettings")
.field("provider", &self.provider)
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("model", &self.model)
.field("base_url", &self.base_url)
.field("http_referer", &self.http_referer)
.field("x_title", &self.x_title)
.field("reasoning", &self.reasoning)
.field("stream_usage", &self.stream_usage)
.finish()
}
}
pub const SUPPORTED_PROVIDERS: [&str; 3] = ["openrouter", "openai", "gemini"];
pub const PROVIDER_ENV_VARS: [&str; 6] = [
"OPENROUTER_API_KEY",
"GEMINI_API_KEY",
"OPENAI_API_KEY",
"AI_API_KEY",
"OPENAI_API_BASE_URL",
"AI_API_BASE_URL",
];
const SELECTED_BY_CONFIG: &str = "`[llm] provider`";
fn stream_usage_for(settings: &LlmSettings, base_url: &str) -> bool {
settings
.stream_usage
.unwrap_or_else(|| base_url.trim_end_matches('/') == OPENAI_BASE_URL)
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum LlmConfigError {
#[error("the {provider} provider is selected by {selected_by}, but is not usable: {detail}")]
Unusable {
provider: &'static str,
selected_by: &'static str,
detail: String,
},
#[error("unsupported LLM provider {name:?}; expected one of: {}", SUPPORTED_PROVIDERS.join(", "))]
Unsupported {
name: String,
},
}
impl LlmConfigError {
fn unusable(
provider: &'static str,
selected_by: &'static str,
detail: impl Into<String>,
) -> Self {
Self::Unusable {
provider,
selected_by,
detail: detail.into(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ReasoningPlan {
#[default]
Unset,
Sent(Reasoning),
Unsupported(Reasoning),
}
impl ReasoningPlan {
pub fn carried(reasoning: Option<Reasoning>) -> Self {
reasoning.map_or(Self::Unset, Self::Sent)
}
pub fn dropped(reasoning: Option<Reasoning>) -> Self {
reasoning.map_or(Self::Unset, Self::Unsupported)
}
pub fn sent(self) -> Option<Reasoning> {
match self {
Self::Sent(reasoning) => Some(reasoning),
Self::Unset | Self::Unsupported(_) => None,
}
}
pub fn requested(self) -> Option<Reasoning> {
match self {
Self::Unset => None,
Self::Sent(reasoning) | Self::Unsupported(reasoning) => Some(reasoning),
}
}
pub fn unsupported(self) -> Option<Reasoning> {
match self {
Self::Unsupported(reasoning) => Some(reasoning),
Self::Unset | Self::Sent(_) => None,
}
}
}
impl std::fmt::Display for ReasoningPlan {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unset => f.write_str("(model default)"),
Self::Sent(reasoning) => write!(f, "{reasoning}"),
Self::Unsupported(reasoning) => write!(f, "{reasoning} (dropped)"),
}
}
}
#[derive(Clone)]
pub struct SelectedLlm {
pub provider: Arc<dyn LlmProvider>,
pub kind: &'static str,
pub model: String,
pub selected_by: &'static str,
pub reasoning: ReasoningPlan,
}
impl std::fmt::Debug for SelectedLlm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SelectedLlm")
.field("kind", &self.kind)
.field("model", &self.model)
.field("selected_by", &self.selected_by)
.field("reasoning", &self.reasoning)
.finish_non_exhaustive()
}
}
pub fn provider_from_env() -> Result<Option<SelectedLlm>, LlmConfigError> {
select_from_env(Env::os())
}
fn select_from_env(env: Env<'_>) -> Result<Option<SelectedLlm>, LlmConfigError> {
let [openrouter_key, gemini_key, openai_vars @ ..] = PROVIDER_ENV_VARS;
if env.get(openrouter_key).is_some() {
let config = OpenAiConfig::openrouter_from_lookup(env)
.map_err(|detail| LlmConfigError::unusable("openrouter", openrouter_key, detail))?;
return Ok(Some(SelectedLlm {
kind: "openrouter",
model: config.model.clone(),
selected_by: openrouter_key,
reasoning: ReasoningPlan::carried(config.reasoning),
provider: Arc::new(OpenAiProvider::new(config)),
}));
}
if env.get(gemini_key).is_some() {
let config = GeminiConfig::from_lookup(env)
.map_err(|detail| LlmConfigError::unusable("gemini", gemini_key, detail))?;
return Ok(Some(SelectedLlm {
kind: "gemini",
model: config.model.clone(),
selected_by: gemini_key,
reasoning: ReasoningPlan::Unset,
provider: Arc::new(GeminiProvider::new(config)),
}));
}
if let Some(var) = openai_vars.into_iter().find(|var| env.get(var).is_some()) {
let config = OpenAiConfig::from_lookup(env);
return Ok(Some(SelectedLlm {
kind: "openai",
model: config.model.clone(),
selected_by: var,
reasoning: ReasoningPlan::Unset,
provider: Arc::new(OpenAiProvider::new(config)),
}));
}
Ok(None)
}
fn warn_unsupported_reasoning(settings: &LlmSettings) {
if let Some(reasoning) = settings.reasoning {
tracing::warn!(
provider = %settings.provider,
%reasoning,
"`reasoning` is only sent to the openrouter provider today; ignoring it"
);
}
}
pub fn provider_from_settings(settings: &LlmSettings) -> Result<SelectedLlm, LlmConfigError> {
build_from_settings(settings, Env::os())
}
fn build_from_settings(
settings: &LlmSettings,
env: Env<'_>,
) -> Result<SelectedLlm, LlmConfigError> {
fn or_env(configured: &Option<String>, env: Env<'_>, keys: &[&str]) -> Option<String> {
configured
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| keys.iter().find_map(|key| env.get(key)))
}
match settings.provider.as_str() {
"openrouter" => {
let api_key =
or_env(&settings.api_key, env, &["OPENROUTER_API_KEY"]).ok_or_else(|| {
LlmConfigError::unusable(
"openrouter",
SELECTED_BY_CONFIG,
"no `api_key` in the config and OPENROUTER_API_KEY is not set",
)
})?;
let model = or_env(&settings.model, env, &["OPENROUTER_MODEL"])
.unwrap_or_else(|| OPENROUTER_DEFAULT_MODEL.to_string());
let mut config = OpenAiConfig {
reasoning: settings.reasoning,
..OpenAiConfig::openrouter(
api_key,
model.clone(),
or_env(&settings.base_url, env, &["OPENROUTER_API_BASE_URL"]),
or_env(&settings.http_referer, env, &["OPENROUTER_HTTP_REFERER"]),
or_env(&settings.x_title, env, &["OPENROUTER_X_TITLE"]),
)
};
if let Some(stream_usage) = settings.stream_usage {
config.stream_usage = stream_usage;
}
Ok(SelectedLlm {
kind: "openrouter",
model,
selected_by: SELECTED_BY_CONFIG,
reasoning: ReasoningPlan::carried(settings.reasoning),
provider: Arc::new(OpenAiProvider::new(config)),
})
}
"openai" => {
warn_unsupported_reasoning(settings);
let model = or_env(&settings.model, env, &["OPENAI_MODEL", "AI_MODEL"])
.unwrap_or_else(|| "gpt-4o-mini".to_string());
let base_url = or_env(
&settings.base_url,
env,
&["OPENAI_API_BASE_URL", "AI_API_BASE_URL"],
)
.unwrap_or_else(|| OPENAI_BASE_URL.to_string());
let config = OpenAiConfig {
stream_usage: stream_usage_for(settings, &base_url),
base_url,
model: model.clone(),
api_key: or_env(&settings.api_key, env, &["OPENAI_API_KEY", "AI_API_KEY"]),
extra_headers: Vec::new(),
supports_reasoning: false,
reasoning: None,
};
Ok(SelectedLlm {
kind: "openai",
model,
selected_by: SELECTED_BY_CONFIG,
reasoning: ReasoningPlan::dropped(settings.reasoning),
provider: Arc::new(OpenAiProvider::new(config)),
})
}
"gemini" => {
warn_unsupported_reasoning(settings);
let model = or_env(&settings.model, env, &["GEMINI_MODEL"])
.unwrap_or_else(|| GEMINI_DEFAULT_MODEL.to_string());
let config = GeminiConfig {
base_url: or_env(&settings.base_url, env, &["GEMINI_API_BASE_URL"])
.unwrap_or_else(|| GEMINI_BASE_URL.to_string()),
api_key: or_env(&settings.api_key, env, &["GEMINI_API_KEY"]).ok_or_else(|| {
LlmConfigError::unusable(
"gemini",
SELECTED_BY_CONFIG,
"no `api_key` in the config and GEMINI_API_KEY is not set",
)
})?,
model: model.clone(),
};
Ok(SelectedLlm {
kind: "gemini",
model,
selected_by: SELECTED_BY_CONFIG,
reasoning: ReasoningPlan::dropped(settings.reasoning),
provider: Arc::new(GeminiProvider::new(config)),
})
}
other => Err(LlmConfigError::Unsupported {
name: other.to_string(),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ReasoningEffort;
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
let pairs: Vec<(String, String)> = pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
move |key| pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
}
#[test]
fn stream_usage_follows_the_endpoint_when_nothing_says_otherwise() {
let settings = LlmSettings::default();
assert!(stream_usage_for(&settings, OPENAI_BASE_URL));
assert!(stream_usage_for(&settings, &format!("{OPENAI_BASE_URL}/")));
assert!(!stream_usage_for(&settings, "http://localhost:11434/v1"));
}
#[test]
fn a_configured_stream_usage_wins_in_both_directions() {
let on = LlmSettings {
stream_usage: Some(true),
..LlmSettings::default()
};
assert!(stream_usage_for(&on, "https://llm.internal/v1"));
let off = LlmSettings {
stream_usage: Some(false),
..LlmSettings::default()
};
assert!(!stream_usage_for(&off, OPENAI_BASE_URL));
}
#[test]
fn nothing_configured_is_not_a_failure() {
assert!(select_from_env(Env::new(&env_of(&[]))).unwrap().is_none());
}
#[test]
fn a_key_selects_its_provider_and_reports_the_model() {
let selected = select_from_env(Env::new(&env_of(&[
("OPENROUTER_API_KEY", "sk-or-test"),
("OPENROUTER_MODEL", "minimax/minimax-m2"),
])))
.expect("a usable key is not an error")
.expect("a key selects a provider");
assert_eq!(selected.kind, "openrouter");
assert_eq!(selected.selected_by, "OPENROUTER_API_KEY");
assert_eq!(selected.model, "minimax/minimax-m2");
}
#[test]
fn a_broken_setting_is_an_error_not_an_absence() {
let error = select_from_env(Env::new(&env_of(&[
("OPENROUTER_API_KEY", "sk-or-test"),
("OPENROUTER_REASONING", "verry-high"),
])))
.expect_err("a malformed OPENROUTER_REASONING is a failure");
assert!(
matches!(&error, LlmConfigError::Unusable { provider, selected_by, .. }
if *provider == "openrouter" && *selected_by == "OPENROUTER_API_KEY"),
"{error}"
);
assert!(
error.to_string().contains("OPENROUTER_REASONING"),
"{error}"
);
}
#[test]
fn a_broken_provider_does_not_fall_through_to_the_next() {
let error = select_from_env(Env::new(&env_of(&[
("OPENROUTER_API_KEY", "sk-or-test"),
("OPENROUTER_REASONING", "verry-high"),
("GEMINI_API_KEY", "gemini-key"),
])))
.expect_err("the broken first choice wins over the working second");
assert!(error.to_string().contains("openrouter"), "{error}");
}
#[test]
fn a_blank_key_does_not_select_a_provider() {
assert!(
select_from_env(Env::new(&env_of(&[("OPENROUTER_API_KEY", " ")])))
.unwrap()
.is_none()
);
}
#[test]
fn a_base_url_alone_selects_the_openai_compatible_provider() {
let selected = select_from_env(Env::new(&env_of(&[(
"OPENAI_API_BASE_URL",
"http://localhost:11434/v1",
)])))
.unwrap()
.expect("a base URL is enough for a local server");
assert_eq!(selected.kind, "openai");
assert_eq!(selected.selected_by, "OPENAI_API_BASE_URL");
}
#[test]
fn settings_without_a_key_read_the_environment() {
let settings = LlmSettings {
provider: "gemini".to_string(),
..Default::default()
};
let env = env_of(&[
("GEMINI_API_KEY", "gemini-key"),
("GEMINI_MODEL", "gemini-3-pro"),
]);
let selected =
build_from_settings(&settings, Env::new(&env)).expect("the env supplies the key");
assert_eq!(selected.kind, "gemini");
assert_eq!(selected.model, "gemini-3-pro");
let error = build_from_settings(&settings, Env::new(&env_of(&[])))
.expect_err("no key anywhere is a failure, not an empty key");
assert!(error.to_string().contains("GEMINI_API_KEY"), "{error}");
}
#[test]
fn the_config_wins_over_the_environment() {
let settings = LlmSettings {
provider: "openrouter".to_string(),
api_key: Some("sk-or-config".to_string()),
model: Some("z-ai/glm-5.2".to_string()),
reasoning: Some(Reasoning::Effort(ReasoningEffort::Low)),
..Default::default()
};
let selected = build_from_settings(
&settings,
Env::new(&env_of(&[("OPENROUTER_MODEL", "some/other-model")])),
)
.expect("a configured key needs no environment");
assert_eq!(selected.model, "z-ai/glm-5.2");
assert_eq!(selected.selected_by, SELECTED_BY_CONFIG);
assert_eq!(
selected.reasoning,
ReasoningPlan::Sent(Reasoning::Effort(ReasoningEffort::Low))
);
}
#[test]
fn a_provider_that_cannot_send_reasoning_reports_what_it_dropped() {
for provider in ["openai", "gemini"] {
let settings = LlmSettings {
provider: provider.to_string(),
api_key: Some("key".to_string()),
reasoning: Some(Reasoning::Effort(ReasoningEffort::High)),
..Default::default()
};
let selected = build_from_settings(&settings, Env::new(&env_of(&[]))).unwrap();
assert_eq!(
selected.reasoning,
ReasoningPlan::Unsupported(Reasoning::Effort(ReasoningEffort::High)),
"for {provider}"
);
assert_eq!(selected.reasoning.sent(), None, "for {provider}");
assert_eq!(
selected.reasoning.requested(),
Some(Reasoning::Effort(ReasoningEffort::High)),
"for {provider}"
);
}
}
#[test]
fn no_reasoning_configured_is_not_a_drop() {
for provider in SUPPORTED_PROVIDERS {
let settings = LlmSettings {
provider: provider.to_string(),
api_key: Some("key".to_string()),
..Default::default()
};
let selected = build_from_settings(&settings, Env::new(&env_of(&[]))).unwrap();
assert_eq!(selected.reasoning, ReasoningPlan::Unset, "for {provider}");
assert_eq!(selected.reasoning.unsupported(), None, "for {provider}");
}
}
#[test]
fn debug_output_redacts_the_api_key() {
let settings = LlmSettings {
provider: "openrouter".to_string(),
api_key: Some("sk-or-supersecret".to_string()),
..Default::default()
};
let printed = format!("{settings:?}");
assert!(!printed.contains("supersecret"), "{printed}");
assert!(printed.contains("redacted"), "{printed}");
}
#[test]
fn an_unknown_provider_names_the_ones_that_exist() {
let settings = LlmSettings {
provider: "opnrouter".to_string(),
..Default::default()
};
let error =
build_from_settings(&settings, Env::new(&env_of(&[]))).expect_err("no such provider");
assert!(matches!(&error, LlmConfigError::Unsupported { name } if name == "opnrouter"));
for provider in SUPPORTED_PROVIDERS {
assert!(error.to_string().contains(provider), "{error}");
}
}
}