1use std::sync::Arc;
26
27use super::{
28 Env, LlmProvider, Reasoning,
29 gemini::{GEMINI_BASE_URL, GeminiConfig, GeminiProvider},
30 openai::{
31 OPENAI_BASE_URL, OPENROUTER_DEFAULT_MODEL, OpenAiConfig, OpenAiProvider, ReasoningDialect,
32 reasoning_effort,
33 },
34};
35
36#[derive(Clone, Default, PartialEq, Eq)]
39pub struct LlmSettings {
40 pub provider: String,
42 pub api_key: Option<String>,
45 pub model: Option<String>,
48 pub base_url: Option<String>,
51 pub http_referer: Option<String>,
53 pub x_title: Option<String>,
55 pub reasoning: Option<Reasoning>,
65 pub stream_usage: Option<bool>,
74}
75
76impl std::fmt::Debug for LlmSettings {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.debug_struct("LlmSettings")
82 .field("provider", &self.provider)
83 .field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
84 .field("model", &self.model)
85 .field("base_url", &self.base_url)
86 .field("http_referer", &self.http_referer)
87 .field("x_title", &self.x_title)
88 .field("reasoning", &self.reasoning)
89 .field("stream_usage", &self.stream_usage)
90 .finish()
91 }
92}
93
94pub const SUPPORTED_PROVIDERS: [&str; 3] = ["openrouter", "openai", "gemini"];
97
98pub const PROVIDER_ENV_VARS: [&str; 6] = [
102 "OPENROUTER_API_KEY",
103 "GEMINI_API_KEY",
104 "OPENAI_API_KEY",
105 "AI_API_KEY",
106 "OPENAI_API_BASE_URL",
107 "AI_API_BASE_URL",
108];
109
110const SELECTED_BY_CONFIG: &str = "`[llm] provider`";
113
114fn stream_usage_for(settings: &LlmSettings, base_url: &str) -> bool {
125 settings
126 .stream_usage
127 .unwrap_or_else(|| base_url.trim_end_matches('/') == OPENAI_BASE_URL)
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
136pub enum LlmConfigError {
137 #[error("the {provider} provider is selected by {selected_by}, but is not usable: {detail}")]
140 Unusable {
141 provider: &'static str,
143 selected_by: &'static str,
145 detail: String,
147 },
148 #[error("unsupported LLM provider {name:?}; expected one of: {}", SUPPORTED_PROVIDERS.join(", "))]
151 Unsupported {
152 name: String,
154 },
155}
156
157impl LlmConfigError {
158 fn unusable(
159 provider: &'static str,
160 selected_by: &'static str,
161 detail: impl Into<String>,
162 ) -> Self {
163 Self::Unusable {
164 provider,
165 selected_by,
166 detail: detail.into(),
167 }
168 }
169}
170
171#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
178pub enum ReasoningPlan {
179 #[default]
181 Unset,
182 Sent(Reasoning),
184 Attempted(Reasoning),
193 Unsupported(Reasoning),
196}
197
198impl ReasoningPlan {
199 pub fn carried(reasoning: Option<Reasoning>) -> Self {
201 reasoning.map_or(Self::Unset, Self::Sent)
202 }
203
204 pub fn attempted(reasoning: Option<Reasoning>) -> Self {
206 reasoning.map_or(Self::Unset, Self::Attempted)
207 }
208
209 pub fn dropped(reasoning: Option<Reasoning>) -> Self {
211 reasoning.map_or(Self::Unset, Self::Unsupported)
212 }
213
214 pub fn sent(self) -> Option<Reasoning> {
218 match self {
219 Self::Sent(reasoning) | Self::Attempted(reasoning) => Some(reasoning),
220 Self::Unset | Self::Unsupported(_) => None,
221 }
222 }
223
224 pub fn requested(self) -> Option<Reasoning> {
226 match self {
227 Self::Unset => None,
228 Self::Sent(reasoning) | Self::Attempted(reasoning) | Self::Unsupported(reasoning) => {
229 Some(reasoning)
230 }
231 }
232 }
233
234 pub fn unsupported(self) -> Option<Reasoning> {
238 match self {
239 Self::Unsupported(reasoning) => Some(reasoning),
240 Self::Unset | Self::Sent(_) | Self::Attempted(_) => None,
241 }
242 }
243
244 pub fn attempting(self) -> Option<Reasoning> {
247 match self {
248 Self::Attempted(reasoning) => Some(reasoning),
249 Self::Unset | Self::Sent(_) | Self::Unsupported(_) => None,
250 }
251 }
252}
253
254impl std::fmt::Display for ReasoningPlan {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 match self {
257 Self::Unset => f.write_str("(model default)"),
258 Self::Sent(reasoning) => write!(f, "{reasoning}"),
259 Self::Attempted(reasoning) => write!(f, "{reasoning} (if the model takes it)"),
260 Self::Unsupported(reasoning) => write!(f, "{reasoning} (dropped)"),
261 }
262 }
263}
264
265#[derive(Clone)]
272pub struct SelectedLlm {
273 pub provider: Arc<dyn LlmProvider>,
275 pub kind: &'static str,
277 pub model: String,
279 pub selected_by: &'static str,
281 pub reasoning: ReasoningPlan,
284}
285
286impl std::fmt::Debug for SelectedLlm {
287 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 f.debug_struct("SelectedLlm")
290 .field("kind", &self.kind)
291 .field("model", &self.model)
292 .field("selected_by", &self.selected_by)
293 .field("reasoning", &self.reasoning)
294 .finish_non_exhaustive()
295 }
296}
297
298pub fn provider_from_env() -> Result<Option<SelectedLlm>, LlmConfigError> {
313 select_from_env(Env::os())
314}
315
316fn select_from_env(env: Env<'_>) -> Result<Option<SelectedLlm>, LlmConfigError> {
317 let [openrouter_key, gemini_key, openai_vars @ ..] = PROVIDER_ENV_VARS;
318
319 if env.get(openrouter_key).is_some() {
320 let config = OpenAiConfig::openrouter_from_lookup(env)
321 .map_err(|detail| LlmConfigError::unusable("openrouter", openrouter_key, detail))?;
322 return Ok(Some(SelectedLlm {
323 kind: "openrouter",
324 model: config.model.clone(),
325 selected_by: openrouter_key,
326 reasoning: ReasoningPlan::carried(config.reasoning),
327 provider: Arc::new(OpenAiProvider::new(config)),
328 }));
329 }
330
331 if env.get(gemini_key).is_some() {
332 let config = GeminiConfig::from_lookup(env)
333 .map_err(|detail| LlmConfigError::unusable("gemini", gemini_key, detail))?;
334 return Ok(Some(SelectedLlm {
335 kind: "gemini",
336 model: config.model.clone(),
337 selected_by: gemini_key,
338 reasoning: ReasoningPlan::Unset,
339 provider: Arc::new(GeminiProvider::new(config)),
340 }));
341 }
342
343 if let Some(var) = openai_vars.into_iter().find(|var| env.get(var).is_some()) {
344 let config = OpenAiConfig::from_lookup(env);
345 return Ok(Some(SelectedLlm {
346 kind: "openai",
347 model: config.model.clone(),
348 selected_by: var,
349 reasoning: ReasoningPlan::Unset,
350 provider: Arc::new(OpenAiProvider::new(config)),
351 }));
352 }
353
354 Ok(None)
355}
356
357fn warn_budget_has_no_openai_field(reasoning: Reasoning) {
365 tracing::warn!(
366 provider = "openai",
367 %reasoning,
368 "the OpenAI chat-completions API has no field for a reasoning token budget; ignoring it"
369 );
370}
371
372pub fn provider_from_settings(settings: &LlmSettings) -> Result<SelectedLlm, LlmConfigError> {
380 build_from_settings(settings, Env::os())
381}
382
383fn build_from_settings(
384 settings: &LlmSettings,
385 env: Env<'_>,
386) -> Result<SelectedLlm, LlmConfigError> {
387 fn or_env(configured: &Option<String>, env: Env<'_>, keys: &[&str]) -> Option<String> {
390 configured
391 .as_deref()
392 .map(str::trim)
393 .filter(|value| !value.is_empty())
394 .map(str::to_string)
395 .or_else(|| keys.iter().find_map(|key| env.get(key)))
396 }
397
398 match settings.provider.as_str() {
399 "openrouter" => {
400 let api_key =
401 or_env(&settings.api_key, env, &["OPENROUTER_API_KEY"]).ok_or_else(|| {
402 LlmConfigError::unusable(
403 "openrouter",
404 SELECTED_BY_CONFIG,
405 "no `api_key` in the config and OPENROUTER_API_KEY is not set",
406 )
407 })?;
408 let model = or_env(&settings.model, env, &["OPENROUTER_MODEL"])
409 .unwrap_or_else(|| OPENROUTER_DEFAULT_MODEL.to_string());
410 let mut config = OpenAiConfig {
411 reasoning: settings.reasoning,
412 ..OpenAiConfig::openrouter(
413 api_key,
414 model.clone(),
415 or_env(&settings.base_url, env, &["OPENROUTER_API_BASE_URL"]),
416 or_env(&settings.http_referer, env, &["OPENROUTER_HTTP_REFERER"]),
417 or_env(&settings.x_title, env, &["OPENROUTER_X_TITLE"]),
418 )
419 };
420 if let Some(stream_usage) = settings.stream_usage {
423 config.stream_usage = stream_usage;
424 }
425 Ok(SelectedLlm {
426 kind: "openrouter",
427 model,
428 selected_by: SELECTED_BY_CONFIG,
429 reasoning: ReasoningPlan::carried(settings.reasoning),
430 provider: Arc::new(OpenAiProvider::new(config)),
431 })
432 }
433 "openai" => {
434 let model = or_env(&settings.model, env, &["OPENAI_MODEL", "AI_MODEL"])
437 .unwrap_or_else(|| "gpt-4o-mini".to_string());
438 let base_url = or_env(
439 &settings.base_url,
440 env,
441 &["OPENAI_API_BASE_URL", "AI_API_BASE_URL"],
442 )
443 .unwrap_or_else(|| OPENAI_BASE_URL.to_string());
444 let config = OpenAiConfig {
445 stream_usage: stream_usage_for(settings, &base_url),
451 base_url,
452 model: model.clone(),
453 api_key: or_env(&settings.api_key, env, &["OPENAI_API_KEY", "AI_API_KEY"]),
454 extra_headers: Vec::new(),
455 reasoning_dialect: ReasoningDialect::OpenAi,
456 reasoning: settings.reasoning,
457 };
458 let plan = match settings.reasoning {
461 Some(reasoning) if reasoning_effort(reasoning).is_none() => {
462 warn_budget_has_no_openai_field(reasoning);
463 ReasoningPlan::Unsupported(reasoning)
464 }
465 reasoning => ReasoningPlan::attempted(reasoning),
466 };
467 Ok(SelectedLlm {
468 kind: "openai",
469 model,
470 selected_by: SELECTED_BY_CONFIG,
471 reasoning: plan,
472 provider: Arc::new(OpenAiProvider::new(config)),
473 })
474 }
475 "gemini" => {
476 let api_key = or_env(&settings.api_key, env, &["GEMINI_API_KEY"]).ok_or_else(|| {
481 LlmConfigError::unusable(
482 "gemini",
483 SELECTED_BY_CONFIG,
484 "no `api_key` in the config and GEMINI_API_KEY is not set",
485 )
486 })?;
487 let model = or_env(&settings.model, env, &["GEMINI_MODEL"]).ok_or_else(|| {
493 LlmConfigError::unusable(
494 "gemini",
495 SELECTED_BY_CONFIG,
496 "no `model` in the config and GEMINI_MODEL is not set",
497 )
498 })?;
499 let config = GeminiConfig {
500 base_url: or_env(&settings.base_url, env, &["GEMINI_API_BASE_URL"])
501 .unwrap_or_else(|| GEMINI_BASE_URL.to_string()),
502 api_key,
503 model: model.clone(),
504 reasoning: settings.reasoning,
505 };
506 Ok(SelectedLlm {
507 kind: "gemini",
508 model,
509 selected_by: SELECTED_BY_CONFIG,
510 reasoning: ReasoningPlan::attempted(settings.reasoning),
513 provider: Arc::new(GeminiProvider::new(config)),
514 })
515 }
516 other => Err(LlmConfigError::Unsupported {
517 name: other.to_string(),
518 }),
519 }
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use crate::ReasoningEffort;
526
527 fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
530 let pairs: Vec<(String, String)> = pairs
531 .iter()
532 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
533 .collect();
534 move |key| pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
535 }
536
537 #[test]
541 fn stream_usage_follows_the_endpoint_when_nothing_says_otherwise() {
542 let settings = LlmSettings::default();
543 assert!(stream_usage_for(&settings, OPENAI_BASE_URL));
544 assert!(stream_usage_for(&settings, &format!("{OPENAI_BASE_URL}/")));
545 assert!(!stream_usage_for(&settings, "http://localhost:11434/v1"));
546 }
547
548 #[test]
551 fn a_configured_stream_usage_wins_in_both_directions() {
552 let on = LlmSettings {
553 stream_usage: Some(true),
554 ..LlmSettings::default()
555 };
556 assert!(stream_usage_for(&on, "https://llm.internal/v1"));
557
558 let off = LlmSettings {
559 stream_usage: Some(false),
560 ..LlmSettings::default()
561 };
562 assert!(!stream_usage_for(&off, OPENAI_BASE_URL));
563 }
564
565 #[test]
566 fn nothing_configured_is_not_a_failure() {
567 assert!(select_from_env(Env::new(&env_of(&[]))).unwrap().is_none());
568 }
569
570 #[test]
571 fn a_key_selects_its_provider_and_reports_the_model() {
572 let selected = select_from_env(Env::new(&env_of(&[
573 ("OPENROUTER_API_KEY", "sk-or-test"),
574 ("OPENROUTER_MODEL", "minimax/minimax-m2"),
575 ])))
576 .expect("a usable key is not an error")
577 .expect("a key selects a provider");
578 assert_eq!(selected.kind, "openrouter");
579 assert_eq!(selected.selected_by, "OPENROUTER_API_KEY");
580 assert_eq!(selected.model, "minimax/minimax-m2");
581 }
582
583 #[test]
587 fn a_broken_setting_is_an_error_not_an_absence() {
588 let error = select_from_env(Env::new(&env_of(&[
589 ("OPENROUTER_API_KEY", "sk-or-test"),
590 ("OPENROUTER_REASONING", "verry-high"),
591 ])))
592 .expect_err("a malformed OPENROUTER_REASONING is a failure");
593 assert!(
594 matches!(&error, LlmConfigError::Unusable { provider, selected_by, .. }
595 if *provider == "openrouter" && *selected_by == "OPENROUTER_API_KEY"),
596 "{error}"
597 );
598 assert!(
599 error.to_string().contains("OPENROUTER_REASONING"),
600 "{error}"
601 );
602 }
603
604 #[test]
607 fn a_broken_provider_does_not_fall_through_to_the_next() {
608 let error = select_from_env(Env::new(&env_of(&[
609 ("OPENROUTER_API_KEY", "sk-or-test"),
610 ("OPENROUTER_REASONING", "verry-high"),
611 ("GEMINI_API_KEY", "gemini-key"),
612 ])))
613 .expect_err("the broken first choice wins over the working second");
614 assert!(error.to_string().contains("openrouter"), "{error}");
615 }
616
617 #[test]
620 fn a_blank_key_does_not_select_a_provider() {
621 assert!(
622 select_from_env(Env::new(&env_of(&[("OPENROUTER_API_KEY", " ")])))
623 .unwrap()
624 .is_none()
625 );
626 }
627
628 #[test]
629 fn a_base_url_alone_selects_the_openai_compatible_provider() {
630 let selected = select_from_env(Env::new(&env_of(&[(
631 "OPENAI_API_BASE_URL",
632 "http://localhost:11434/v1",
633 )])))
634 .unwrap()
635 .expect("a base URL is enough for a local server");
636 assert_eq!(selected.kind, "openai");
637 assert_eq!(selected.selected_by, "OPENAI_API_BASE_URL");
638 }
639
640 #[test]
643 fn settings_without_a_key_read_the_environment() {
644 let settings = LlmSettings {
645 provider: "gemini".to_string(),
646 ..Default::default()
647 };
648 let env = env_of(&[
649 ("GEMINI_API_KEY", "gemini-key"),
650 ("GEMINI_MODEL", "gemini-3-pro"),
651 ]);
652 let selected =
653 build_from_settings(&settings, Env::new(&env)).expect("the env supplies the key");
654 assert_eq!(selected.kind, "gemini");
655 assert_eq!(selected.model, "gemini-3-pro");
656
657 let error = build_from_settings(&settings, Env::new(&env_of(&[])))
658 .expect_err("no key anywhere is a failure, not an empty key");
659 assert!(error.to_string().contains("GEMINI_API_KEY"), "{error}");
660 }
661
662 #[test]
669 fn gemini_names_no_default_model() {
670 let settings = LlmSettings {
671 provider: "gemini".to_string(),
672 api_key: Some("gemini-key".to_string()),
673 ..Default::default()
674 };
675 let error = build_from_settings(&settings, Env::new(&env_of(&[])))
676 .expect_err("a key without a model cannot pick one");
677 assert!(error.to_string().contains("GEMINI_MODEL"), "{error}");
678
679 let selected = build_from_settings(
681 &settings,
682 Env::new(&env_of(&[("GEMINI_MODEL", "gemini-2.5-pro")])),
683 )
684 .expect("the env names the model");
685 assert_eq!(selected.model, "gemini-2.5-pro");
686
687 let error = select_from_env(Env::new(&env_of(&[("GEMINI_API_KEY", "gemini-key")])))
691 .expect_err("a key selects gemini, which then has no model");
692 assert!(error.to_string().contains("GEMINI_MODEL"), "{error}");
693
694 let selected = select_from_env(Env::new(&env_of(&[
695 ("GEMINI_API_KEY", "gemini-key"),
696 ("GEMINI_MODEL", "gemini-2.5-pro"),
697 ])))
698 .unwrap()
699 .expect("both halves present");
700 assert_eq!(selected.kind, "gemini");
701 assert_eq!(selected.model, "gemini-2.5-pro");
702 }
703
704 #[test]
705 fn the_config_wins_over_the_environment() {
706 let settings = LlmSettings {
707 provider: "openrouter".to_string(),
708 api_key: Some("sk-or-config".to_string()),
709 model: Some("z-ai/glm-5.2".to_string()),
710 reasoning: Some(Reasoning::Effort(ReasoningEffort::Low)),
711 ..Default::default()
712 };
713 let selected = build_from_settings(
714 &settings,
715 Env::new(&env_of(&[("OPENROUTER_MODEL", "some/other-model")])),
716 )
717 .expect("a configured key needs no environment");
718 assert_eq!(selected.model, "z-ai/glm-5.2");
719 assert_eq!(selected.selected_by, SELECTED_BY_CONFIG);
720 assert_eq!(
721 selected.reasoning,
722 ReasoningPlan::Sent(Reasoning::Effort(ReasoningEffort::Low))
723 );
724 }
725
726 #[test]
729 fn a_level_is_carried_by_openrouter_and_attempted_elsewhere() {
730 let plan_for = |provider: &str| {
731 let settings = LlmSettings {
732 provider: provider.to_string(),
733 api_key: Some("key".to_string()),
734 model: Some("a-model".to_string()),
737 reasoning: Some(Reasoning::Effort(ReasoningEffort::High)),
738 ..Default::default()
739 };
740 build_from_settings(&settings, Env::new(&env_of(&[])))
741 .unwrap()
742 .reasoning
743 };
744
745 let high = Reasoning::Effort(ReasoningEffort::High);
746 assert_eq!(plan_for("openrouter"), ReasoningPlan::Sent(high));
747 for provider in ["openai", "gemini"] {
748 let plan = plan_for(provider);
749 assert_eq!(plan, ReasoningPlan::Attempted(high), "for {provider}");
750 assert_eq!(plan.sent(), Some(high), "for {provider}");
753 assert_eq!(plan.unsupported(), None, "for {provider}");
754 assert_eq!(plan.attempting(), Some(high), "for {provider}");
755 }
756 }
757
758 #[test]
762 fn a_token_budget_is_a_drop_on_openai_and_carried_on_gemini() {
763 let plan_for = |provider: &str| {
764 let settings = LlmSettings {
765 provider: provider.to_string(),
766 api_key: Some("key".to_string()),
767 model: Some("a-model".to_string()),
768 reasoning: Some(Reasoning::Budget(2000)),
769 ..Default::default()
770 };
771 build_from_settings(&settings, Env::new(&env_of(&[])))
772 .unwrap()
773 .reasoning
774 };
775
776 let openai = plan_for("openai");
777 assert_eq!(openai, ReasoningPlan::Unsupported(Reasoning::Budget(2000)));
778 assert_eq!(openai.sent(), None);
779 assert_eq!(openai.requested(), Some(Reasoning::Budget(2000)));
780
781 assert_eq!(
782 plan_for("gemini"),
783 ReasoningPlan::Attempted(Reasoning::Budget(2000))
784 );
785 }
786
787 #[test]
790 fn no_reasoning_configured_is_not_a_drop() {
791 for provider in SUPPORTED_PROVIDERS {
792 let settings = LlmSettings {
793 provider: provider.to_string(),
794 api_key: Some("key".to_string()),
795 model: Some("a-model".to_string()),
796 ..Default::default()
797 };
798 let selected = build_from_settings(&settings, Env::new(&env_of(&[]))).unwrap();
799 assert_eq!(selected.reasoning, ReasoningPlan::Unset, "for {provider}");
800 assert_eq!(selected.reasoning.unsupported(), None, "for {provider}");
801 }
802 }
803
804 #[test]
808 fn debug_output_redacts_the_api_key() {
809 let settings = LlmSettings {
810 provider: "openrouter".to_string(),
811 api_key: Some("sk-or-supersecret".to_string()),
812 ..Default::default()
813 };
814 let printed = format!("{settings:?}");
815 assert!(!printed.contains("supersecret"), "{printed}");
816 assert!(printed.contains("redacted"), "{printed}");
817 }
818
819 #[test]
820 fn an_unknown_provider_names_the_ones_that_exist() {
821 let settings = LlmSettings {
822 provider: "opnrouter".to_string(),
823 ..Default::default()
824 };
825 let error =
826 build_from_settings(&settings, Env::new(&env_of(&[]))).expect_err("no such provider");
827 assert!(matches!(&error, LlmConfigError::Unsupported { name } if name == "opnrouter"));
828 for provider in SUPPORTED_PROVIDERS {
829 assert!(error.to_string().contains(provider), "{error}");
830 }
831 }
832}