Skip to main content

a2a_llm/
provider.rs

1//! Centralized [`LlmProvider`] selection.
2//!
3//! This is the single place agents pick a concrete LLM provider, replacing the
4//! ad-hoc `GeminiProvider::from_env()` / `OpenAiProvider::from_env()` cascades
5//! that used to be copy-pasted across handlers, examples, and the CLI.
6//!
7//! Two entry points:
8//! - [`provider_from_env`] — env-driven selection (OpenRouter → Gemini → OpenAI).
9//! - [`provider_from_settings`] — config-driven selection from [`LlmSettings`].
10//!
11//! Both separate "nothing is configured" (`Ok(None)`) from "what is configured
12//! does not work" ([`LlmConfigError`]). These used to both return `None`, so a
13//! mistyped key started the agent anyway and it answered from its non-LLM
14//! fallback.
15//!
16//! Selection performs no I/O, so `korps doctor` can run the same code as startup
17//! to decide what startup will do. The only output is a warning when a
18//! configured `reasoning` has no field to go in on the selected provider — the
19//! cases the model decides are the provider's to report, and only at run time.
20//!
21//! Settings are expressed with this crate's own [`LlmSettings`] type rather than
22//! a host's config struct so the helper takes no dependency on `korps`
23//! (which would be circular).
24
25use 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/// Provider-agnostic LLM settings sourced from a host's configuration
37/// (TOML, CLI flags, etc.). Mirrors the fields a host typically exposes.
38#[derive(Clone, Default, PartialEq, Eq)]
39pub struct LlmSettings {
40    /// Provider selector: `"openrouter"`, `"openai"`, or `"gemini"`.
41    pub provider: String,
42    /// API key. When `None`, the provider's own environment variable is read
43    /// instead.
44    pub api_key: Option<String>,
45    /// Model identifier. Environment, then a provider-specific default, applied
46    /// when `None`.
47    pub model: Option<String>,
48    /// Base URL override. Environment, then a provider-specific default,
49    /// applied when `None`.
50    pub base_url: Option<String>,
51    /// OpenRouter `HTTP-Referer` attribution header (ignored by other providers).
52    pub http_referer: Option<String>,
53    /// OpenRouter `X-Title` attribution header (ignored by other providers).
54    pub x_title: Option<String>,
55    /// What to ask this model to do with its thinking, for every request that
56    /// doesn't ask for its own. `None` leaves the model's default alone.
57    ///
58    /// Every provider carries it now, in its own dialect. What differs is when
59    /// the answer is known: OpenRouter takes any of it
60    /// ([`ReasoningPlan::Sent`]), OpenAI has no field for a token budget
61    /// ([`ReasoningPlan::Unsupported`]), and elsewhere it is the model that
62    /// accepts or refuses, which only the first call finds out
63    /// ([`ReasoningPlan::Attempted`]).
64    pub reasoning: Option<Reasoning>,
65    /// Whether the endpoint accepts `stream_options.include_usage`, which is
66    /// what makes a *streaming* response report what it cost.
67    ///
68    /// `None` leaves it to the endpoint: on for OpenRouter and OpenAI's own
69    /// URL, off elsewhere, because a local OpenAI-compatible server that
70    /// rejects unknown parameters fails the whole call. Set it for a server
71    /// this crate has no way to recognize — a proxy in front of OpenAI, or a
72    /// self-hosted vLLM that does support it.
73    pub stream_usage: Option<bool>,
74}
75
76impl std::fmt::Debug for LlmSettings {
77    /// Hand-written to keep `api_key` out of the output. These settings travel
78    /// inside a `doctor` requirement, so anything that prints one would print
79    /// the key with it.
80    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
94/// Every provider [`provider_from_settings`] can build, for the error message
95/// that lists them.
96pub const SUPPORTED_PROVIDERS: [&str; 3] = ["openrouter", "openai", "gemini"];
97
98/// Every environment variable that can select a provider, in the order
99/// [`provider_from_env`] prefers them. Public so a host can list them in a
100/// report instead of keeping its own copy.
101pub 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
110/// How the config path names itself in an error, where the env path names the
111/// variable it read.
112const SELECTED_BY_CONFIG: &str = "`[llm] provider`";
113
114/// Whether to ask a streaming request to report what it cost.
115///
116/// The config decides when it says anything. Otherwise the endpoint does, and
117/// the only endpoint whose support is *known* rather than guessed is OpenAI's
118/// own — everything else on this branch is a local OpenAI-compatible server,
119/// which may reject the parameter and fail the whole call.
120///
121/// The URL is compared with a trailing slash trimmed: `https://api.openai.com/v1/`
122/// names the same endpoint, and a plain string comparison quietly decides it is
123/// some other server.
124fn 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/// A provider is named but cannot be built.
131///
132/// Raised before any request is made, so a host can refuse to start instead of
133/// failing on the first message. Errors from calling a provider are
134/// [`LlmError`](super::LlmError).
135#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
136pub enum LlmConfigError {
137    /// A provider is named and its settings cannot be read: a missing key, a
138    /// malformed `OPENROUTER_REASONING`.
139    #[error("the {provider} provider is selected by {selected_by}, but is not usable: {detail}")]
140    Unusable {
141        /// Which provider was selected.
142        provider: &'static str,
143        /// What selected it — an environment variable, or `[llm] provider`.
144        selected_by: &'static str,
145        /// What is wrong with its settings.
146        detail: String,
147    },
148    /// [`LlmSettings::provider`] names something no adapter implements —
149    /// usually a typo.
150    #[error("unsupported LLM provider {name:?}; expected one of: {}", SUPPORTED_PROVIDERS.join(", "))]
151    Unsupported {
152        /// The provider string as configured.
153        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/// What a configured [`Reasoning`] will do on the provider that was selected.
172///
173/// Reasoning tokens are billed, so "asked for, and this provider has nowhere to
174/// put it" has to be tellable from "never asked for". Both used to resolve to
175/// the same `None` on [`SelectedLlm`], which left `korps doctor` unable to report
176/// a setting the run would quietly discard.
177#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
178pub enum ReasoningPlan {
179    /// Nothing was configured; the model's own default stands.
180    #[default]
181    Unset,
182    /// Configured, and this provider puts it on the wire.
183    Sent(Reasoning),
184    /// Configured, and this provider sends it — but whether the *model* accepts
185    /// it is not known until the first call.
186    ///
187    /// OpenAI's `reasoning_effort` and Gemini's `thinkingConfig` are refused by
188    /// the models that do not reason, and which models those are changes with
189    /// every release. So the parameter is sent, a refusal is recognized from the
190    /// 400, and the request is retried without it — meaning this setting may
191    /// still end up dropped, and only the run knows.
192    Attempted(Reasoning),
193    /// Configured, and this provider has no field that carries it. Dropped
194    /// before any request is made.
195    Unsupported(Reasoning),
196}
197
198impl ReasoningPlan {
199    /// The plan for a provider that can carry whatever was configured.
200    pub fn carried(reasoning: Option<Reasoning>) -> Self {
201        reasoning.map_or(Self::Unset, Self::Sent)
202    }
203
204    /// The plan for a provider that sends it and finds out from the model.
205    pub fn attempted(reasoning: Option<Reasoning>) -> Self {
206        reasoning.map_or(Self::Unset, Self::Attempted)
207    }
208
209    /// The plan for a provider with no reasoning field.
210    pub fn dropped(reasoning: Option<Reasoning>) -> Self {
211        reasoning.map_or(Self::Unset, Self::Unsupported)
212    }
213
214    /// What reaches the wire, if anything. A setting dropped before the request
215    /// reads as nothing here, because nothing is what gets sent; an attempted
216    /// one reads as sent, because it is on the first request.
217    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    /// What was configured, whether or not it will be sent.
225    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    /// What this provider was asked for and will not send. Only the settings
235    /// answered before a request is made — see [`Self::attempted`] for the ones
236    /// the model gets the last word on.
237    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    /// What this provider will send and may still have refused. `None` when the
245    /// answer is already known either way.
246    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/// A resolved provider plus what a startup line or `korps doctor` needs to
266/// describe it.
267///
268/// [`model`](Self::model) is resolved here because a config often leaves it to
269/// the environment, and `OPENROUTER_MODEL` is per process: a fleet that omits
270/// it runs every agent on the same model.
271#[derive(Clone)]
272pub struct SelectedLlm {
273    /// The provider adapter, ready to use.
274    pub provider: Arc<dyn LlmProvider>,
275    /// Which adapter it is — one of [`SUPPORTED_PROVIDERS`].
276    pub kind: &'static str,
277    /// The model it will call, after config and environment defaults.
278    pub model: String,
279    /// What selected it: an environment variable name, or `[llm] provider`.
280    pub selected_by: &'static str,
281    /// What will be asked of the model's thinking on requests that don't ask
282    /// for their own, and whether this provider can ask it at all.
283    pub reasoning: ReasoningPlan,
284}
285
286impl std::fmt::Debug for SelectedLlm {
287    /// Hand-written because `dyn LlmProvider` is not `Debug`.
288    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
298/// Select a provider from the environment.
299///
300/// Preference order, each gated on a *present* key:
301///
302/// 1. **OpenRouter** when `OPENROUTER_API_KEY` is set.
303/// 2. **Gemini** when `GEMINI_API_KEY` is set.
304/// 3. **OpenAI-compatible** when any of `OPENAI_API_KEY`, `AI_API_KEY`,
305///    `OPENAI_API_BASE_URL`, or `AI_API_BASE_URL` is set (covers local Ollama).
306///
307/// `Ok(None)` means no variable names a provider; the host should use its
308/// non-LLM fallback. `Err` means one does and could not be built — a failing
309/// provider does not fall through to the next, since substituting a different
310/// model (and a different bill) for the one that was asked for is worse than
311/// failing.
312pub 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
357/// Warn that a configured token budget has nowhere to go on OpenAI.
358///
359/// The one reasoning drop selection can still report: every other case is the
360/// model's answer, given at run time. Reasoning tokens are billed, so this says
361/// it at startup rather than leaving the caller to infer it from the bill.
362/// `korps run` has only this log line; a report reads
363/// [`SelectedLlm::reasoning`] instead.
364fn 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
372/// Build a provider from explicit [`LlmSettings`].
373///
374/// Resolution order for every value: the config, then the provider's own
375/// environment variables, then a built-in default. So a `[llm]` block with no
376/// `api_key` reads the key from the environment, which is how the shipped
377/// examples are written. (Previously only `openrouter` did this; `gemini` used
378/// an empty key and `openai` used none, and both failed at the endpoint.)
379pub 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    /// Config first, then the environment. An empty or whitespace-only
388    /// configured value counts as absent.
389    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            // OpenRouter takes `stream_options`, but a proxy in front of it may
421            // not — the config gets the last word wherever it has one.
422            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            // No key is a valid OpenAI-compatible setup (a local Ollama), so
435            // there is nothing to require here — and nothing to report either.
436            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_options.include_usage` is known to work on OpenAI's own
446                // endpoint. This branch also serves local OpenAI-compatible
447                // servers, which vary on it and reject unknown parameters
448                // outright, and nothing here can tell them apart beyond the URL —
449                // so a config that knows better says so.
450                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            // A token budget has no `reasoning_effort` to go in, so that one is
459            // answered here; a level is the model's to accept or refuse.
460            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            // The key first, so a config supplying neither reports the
477            // credential rather than the model: without a key nothing about
478            // this provider is reachable, and naming the model would send a
479            // caller to fix the second thing wrong with it.
480            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            // The one provider with no default model, for the reason
488            // `gemini.rs` gives: the default it used to carry named a model
489            // Google stopped listing, and a stale default is invisible in a way
490            // a missing one is not. `openai` and `openrouter` keep theirs,
491            // which still name models their vendors list.
492            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                // Every `Reasoning` has a `thinkingConfig` spelling, so nothing
511                // is dropped here — the model decides.
512                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    /// A fake environment. Mutating the real one would race the other tests in
528    /// this binary (`set_var` is `unsafe` in edition 2024 for that reason).
529    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    /// Streaming usage is opt-in per endpoint because a server that rejects
538    /// unknown parameters fails the whole call — but the same endpoint written
539    /// with a trailing slash used to silently lose it.
540    #[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    /// The point of the field: a server this crate cannot recognize — a proxy
549    /// in front of OpenAI, a self-hosted vLLM — is the config's to describe.
550    #[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    /// A key that is set but unusable must not report as "unconfigured": the
584    /// host reads that as "use the non-LLM fallback" and the agent answers with
585    /// a stub.
586    #[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    /// A broken first choice fails rather than falling through: the operator
605    /// asked for OpenRouter, and billing Gemini instead is not a fix.
606    #[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    /// `.env` files leave whitespace-only values behind. Treating one as set
618    /// selects a provider that cannot authenticate.
619    #[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    /// A config with no `api_key` reads the key from the environment. It used to
641    /// build an empty key, which failed only on the first message.
642    #[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    /// Gemini is the one provider that names no default model. The default it
663    /// used to carry was `gemini-1.5-pro`, which Google stopped listing while
664    /// every config that omitted a model went on running it — so the absence
665    /// has to be an error a caller sees, not a value this crate invents. Both
666    /// entry points have to agree, since a config and a bare environment reach
667    /// the provider by different code.
668    #[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        // The environment supplies it just as the config would.
680        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        // Selection from a bare environment is the other path in, and a key on
688        // its own is enough to *choose* gemini — so it must fail here too
689        // rather than fall through to another provider or a made-up model.
690        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    /// A level reaches every provider now, but only OpenRouter's answer is known
727    /// before the call: the other two send it and find out from the model.
728    #[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                // Named because `gemini` has no default to fall back on; the
735                // subject here is the plan, not where the model came from.
736                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            // On the wire on the first call, so `sent` says so; and not a drop,
751            // so `korps doctor` does not warn about a setting that may well work.
752            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    /// A token budget is the one setting answered without asking a model:
759    /// `reasoning_effort` has no field for it, so `korps doctor` can still warn
760    /// before a request is billed. Gemini has `thinkingBudget` and takes it.
761    #[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    /// Nothing configured stays nothing. A plan that reported a drop here would
788    /// have `doctor` warning about a setting no config contains.
789    #[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    /// The error lists the valid providers, since the usual cause is a typo.
805    /// These settings ride inside a `korps doctor` requirement, and a report or a
806    /// test failure that prints one must not print the key.
807    #[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}