use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::Result;
use toml::Value;
use crate::auth::{AuthStore, Declared, KeySource};
use crate::config;
use super::DoctorArgs;
pub(super) async fn write_llm_section<W: Write>(
out: &mut W,
args: &DoctorArgs,
root: &Path,
auth_path: &Path,
site: Option<&config::site::SiteConfig>,
semantic: super::Semantic,
codex_probe: &dyn Fn() -> Result<crate::llm::codex::CodexStatus, String>,
) -> Result<()> {
writeln!(out)?;
writeln!(out, "LLM analysis (required):")?;
let config_path: PathBuf = match &args.config {
Some(p) => p.clone(),
None => root.join(config::default_config_path()),
};
let raw = match std::fs::read_to_string(&config_path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
writeln!(
out,
" No config file at {} - `drep check` cannot run. Run `drep init`.",
config_path.display()
)?;
return Ok(());
}
Err(err) => {
writeln!(out, " {} could not be read: {err}", config_path.display())?;
return Ok(());
}
};
let value: toml::Value = match toml::from_str(&raw) {
Ok(v) => v,
Err(err) => {
writeln!(
out,
" {} could not be parsed: {}",
config_path.display(),
err.message()
)?;
return Ok(());
}
};
let providers = match value.get("llm") {
None => {
writeln!(
out,
" {} declares no `[[llm]]` provider. Run `drep init`.",
config_path.display()
)?;
return Ok(());
}
Some(Value::Array(entries)) if entries.is_empty() => {
writeln!(
out,
" {} declares no `[[llm]]` provider. Run `drep init`.",
config_path.display()
)?;
return Ok(());
}
Some(Value::Array(entries)) => entries,
Some(_) => {
writeln!(
out,
" {} has an `llm` key that is not a `[[llm]]` array of tables. \
Providers are declared as `[[llm]]`, one block per provider.",
config_path.display()
)?;
report_load_failure(out, &config_path)?;
return Ok(());
}
};
let loaded = config::load(&config_path);
let needs_auth_store = semantic.skip_reason().is_none()
&& providers
.iter()
.any(|entry| entry_is_enabled(entry) && !entry_is_codex(entry));
let store = match needs_auth_store
.then(|| AuthStore::load(auth_path))
.transpose()
{
Ok(Some(store)) => store,
Ok(None) => AuthStore::new(),
Err(err) => {
writeln!(out, " The auth store could not be read: {err}")?;
AuthStore::new()
}
};
let mut enabled_count = 0usize;
let mut codex_status: Option<Result<crate::llm::codex::CodexStatus, String>> = None;
for (file_index, entry) in providers.iter().enumerate() {
let model = entry
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("(no model set)");
let endpoint = entry
.get("endpoint")
.and_then(|v| v.as_str())
.unwrap_or("(no endpoint set)");
let protocol = match entry.get("protocol").and_then(|v| v.as_str()) {
None | Some("openai") => String::new(),
Some(other) => format!(" [{other}]"),
};
let is_codex = entry_is_codex(entry);
let description = if is_codex {
format!("{model} via ChatGPT/Codex subscription")
} else {
format!("{model} at {endpoint}{protocol}")
};
if entry_is_enabled(entry) {
enabled_count += 1;
writeln!(out, " {enabled_count}. {description}")?;
if let Some(note) = super::site_section::clamp_note(entry, site) {
writeln!(out, " {note}")?;
}
if is_codex {
match semantic.skip_reason() {
Some(reason) => writeln!(out, " Codex CLI: {reason}")?,
None => {
let status = codex_status.get_or_insert_with(codex_probe);
match status {
Ok(status) => {
writeln!(out, " Codex CLI: {}", status.cli_version())?;
writeln!(out, " authentication: ChatGPT-managed")?;
writeln!(
out,
" isolation: ephemeral, read-only, tools disabled"
)?;
}
Err(err) => writeln!(out, " unavailable: {err}")?,
}
}
}
} else {
let line = key_source_line(
entry,
expanded_command(&loaded, file_index),
&store,
semantic,
)
.await;
writeln!(out, " key: {line}")?;
}
} else {
writeln!(out, " - {description} (disabled - skipped)")?;
}
}
writeln!(out, " {}", failover_line(enabled_count))?;
for name in unset_env_vars(&value) {
writeln!(
out,
" {name} is NOT set - LLM analysis will fail until you export it."
)?;
}
match loaded {
Err(config::ConfigError::EnvVarUnset(_, _)) => Ok(()),
other => report_load_result(out, &config_path, other),
}
}
fn expanded_command(
loaded: &Result<config::Config, config::ConfigError>,
file_index: usize,
) -> Option<&[String]> {
loaded
.as_ref()
.ok()?
.llm
.get(file_index)?
.api_key_command
.as_deref()
}
async fn key_source_line(
entry: &Value,
resolved_argv: Option<&[String]>,
store: &AuthStore,
semantic: super::Semantic,
) -> String {
let api_key = entry.get("api_key").and_then(|v| v.as_str());
if let Some(reason) = semantic.skip_reason() {
return if entry.get("api_key_command").is_some() {
format!("{} - {reason}", KeySource::Command.label())
} else {
reason.to_owned()
};
}
let source = crate::auth::source_of(
Declared {
api_key,
has_api_key_command: entry.get("api_key_command").is_some(),
endpoint: entry.get("endpoint").and_then(|v| v.as_str()),
enabled: true,
},
store,
);
match (source, api_key) {
(KeySource::Config, Some(reference))
if !config::env_var_refs_in(&Value::String(reference.to_string())).is_empty() =>
{
format!("{reference} ({})", KeySource::Config.label())
}
(KeySource::Config, _) => format!(
"a literal value ({}) - prefer `${{VAR}}` so the file can be committed",
KeySource::Config.label()
),
(KeySource::Command, _) => format!(
"{} - {}",
KeySource::Command.label(),
key_command_status(resolved_argv).await
),
(source, _) => source.label().to_string(),
}
}
async fn key_command_status(argv: Option<&[String]>) -> String {
let Some(argv) = argv else {
return "not attempted, because the config below does not load".to_owned();
};
match crate::auth::probe_key_command(argv).await {
Ok(()) => "the command ran and printed a credential".to_owned(),
Err(err) => format!("FAILED - {err}"),
}
}
fn report_load_failure<W: Write>(out: &mut W, config_path: &Path) -> Result<()> {
let loaded = config::load(config_path);
report_load_result(out, config_path, loaded)
}
fn report_load_result<W: Write>(
out: &mut W,
config_path: &Path,
loaded: Result<config::Config, config::ConfigError>,
) -> Result<()> {
if let Err(err) = loaded {
writeln!(out, " {} will not load: {err}", config_path.display())?;
}
Ok(())
}
fn entry_is_codex(entry: &toml::Value) -> bool {
entry.get("backend").and_then(toml::Value::as_str) == Some("codex")
}
fn entry_is_enabled(entry: &toml::Value) -> bool {
entry
.get("enabled")
.and_then(toml::Value::as_bool)
.unwrap_or_else(|| config::LlmConfig::default().enabled)
}
fn failover_line(enabled: usize) -> String {
match enabled {
0 => "Every provider is disabled - `drep check` cannot run. Re-enable one.".to_owned(),
1 => "One provider, so there is no fallback: if it is unreachable, `drep check` exits 2."
.to_owned(),
n => format!(
"{n} providers, tried in order: a transport failure falls through to the \
next. A 401 or 403 does not - that is misconfiguration, and failing \
over would hide it."
),
}
}
pub(super) fn unset_env_vars(value: &toml::Value) -> Vec<String> {
config::required_env_var_refs(value)
.into_iter()
.filter(|name| std::env::var_os(name).is_none())
.collect()
}