use crate::config::Config;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Credential {
ApiKey,
BaseUrl,
None,
}
#[derive(Debug, Clone)]
pub struct Provider {
pub id: &'static str,
pub display: &'static str,
pub blurb: &'static str,
pub credential: Credential,
pub hint: &'static str,
pub env_var: Option<&'static str>,
pub signup_url: Option<&'static str>,
}
pub fn providers() -> Vec<Provider> {
vec![
Provider {
id: "anthropic",
display: "Anthropic",
blurb: "Claude models. The default for every shipped blueprint.",
credential: Credential::ApiKey,
hint: "sk-ant-...",
env_var: Some("ANTHROPIC_API_KEY"),
signup_url: Some("https://console.anthropic.com/settings/keys"),
},
Provider {
id: "openai",
display: "OpenAI",
blurb: "GPT models.",
credential: Credential::ApiKey,
hint: "sk-...",
env_var: Some("OPENAI_API_KEY"),
signup_url: Some("https://platform.openai.com/api-keys"),
},
Provider {
id: "google",
display: "Google (Gemini)",
blurb: "Gemini models.",
credential: Credential::ApiKey,
hint: "AIza...",
env_var: Some("GOOGLE_API_KEY"),
signup_url: Some("https://aistudio.google.com/app/apikey"),
},
Provider {
id: "openrouter",
display: "OpenRouter",
blurb: "One key, many vendors' models.",
credential: Credential::ApiKey,
hint: "sk-or-...",
env_var: Some("OPENROUTER_API_KEY"),
signup_url: Some("https://openrouter.ai/keys"),
},
Provider {
id: "ollama",
display: "Ollama (local)",
blurb: "Models running on this machine. No key needed.",
credential: Credential::BaseUrl,
hint: DEFAULT_OLLAMA_URL,
env_var: Some("OLLAMA_HOST"),
signup_url: Some("https://ollama.com/download"),
},
Provider {
id: "claude-code",
display: "Claude Code transport",
blurb: "Runs on your Claude subscription instead of an API key. \
⚠️ May conflict with Anthropic's terms for third-party \
apps. The CLI adds ~130 tokens of its own context to every \
call, including your account email. This cannot be \
disabled.",
credential: Credential::None,
hint: "",
env_var: None,
signup_url: None,
},
]
}
pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
pub const OLLAMA_MAX_CONCURRENT_INFERENCES: usize = 1;
pub fn stored_credential(config: &Config, id: &str) -> Option<String> {
match id {
"anthropic" => config.providers.anthropic_api_key.clone(),
"openai" => config.providers.openai_api_key.clone(),
"google" => config.providers.google_api_key.clone(),
"openrouter" => config.openrouter_api_key.clone(),
"ollama" => config.ollama_base_url.clone(),
_ => None,
}
}
pub fn set_credential(config: &mut Config, id: &str, value: Option<String>) {
match id {
"anthropic" => config.providers.anthropic_api_key = value,
"openai" => config.providers.openai_api_key = value,
"google" => config.providers.google_api_key = value,
"openrouter" => config.openrouter_api_key = value,
"ollama" => config.ollama_base_url = value,
_ => {}
}
}
pub fn is_configured(config: &Config, id: &str) -> bool {
match id {
"claude-code" => config.providers.claude_code_enabled,
_ => stored_credential(config, id).is_some(),
}
}
pub fn redact(key: &str) -> String {
leviath_core::redact(key)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_provider_has_a_distinct_id_and_is_described() {
let all = providers();
let mut ids: Vec<&str> = all.iter().map(|p| p.id).collect();
ids.sort_unstable();
let total = ids.len();
ids.dedup();
assert_eq!(total, ids.len(), "duplicate provider ids");
for p in &all {
assert!(!p.display.is_empty(), "provider {} has no label", p.id);
assert!(!p.blurb.is_empty(), "provider {} has no blurb", p.id);
}
}
#[test]
fn every_api_key_provider_names_its_env_var_and_a_place_to_get_one() {
for p in providers()
.iter()
.filter(|p| p.credential == Credential::ApiKey)
{
assert!(p.env_var.is_some(), "{} has no env var", p.id);
assert!(p.signup_url.is_some(), "{} has no signup URL", p.id);
assert!(!p.hint.is_empty(), "{} has no placeholder", p.id);
}
}
#[test]
fn the_table_covers_every_credential_kind() {
let all = providers();
assert!(all.iter().any(|p| p.credential == Credential::ApiKey));
assert!(all.iter().any(|p| p.credential == Credential::BaseUrl));
assert!(all.iter().any(|p| p.credential == Credential::None));
}
#[test]
fn the_default_provider_is_in_the_table() {
let config = Config::default();
assert!(
providers().iter().any(|p| p.id == config.default_provider),
"default_provider {} is not offered by the wizard",
config.default_provider
);
}
#[test]
fn claude_code_states_its_privacy_cost_up_front() {
let all = providers();
let cc = all
.iter()
.find(|p| p.id == "claude-code")
.expect("the transport is offered");
assert!(cc.blurb.contains("email"));
assert!(cc.blurb.contains("cannot be disabled"));
assert!(cc.blurb.contains("terms"));
assert_eq!(cc.credential, Credential::None);
assert!(!Config::default().providers.claude_code_enabled);
}
#[test]
fn every_provider_with_a_credential_round_trips_through_the_config() {
for p in providers()
.iter()
.filter(|p| p.credential != Credential::None)
{
let mut config = Config::default();
assert!(
stored_credential(&config, p.id).is_none(),
"{} starts set",
p.id
);
set_credential(&mut config, p.id, Some("value".to_string()));
assert_eq!(
stored_credential(&config, p.id).as_deref(),
Some("value"),
"{} did not round trip",
p.id
);
assert!(is_configured(&config, p.id), "{} reads unconfigured", p.id);
set_credential(&mut config, p.id, None);
assert!(
stored_credential(&config, p.id).is_none(),
"{} did not clear",
p.id
);
assert!(!is_configured(&config, p.id));
}
}
#[test]
fn an_unknown_provider_id_stores_nothing_and_reads_back_nothing() {
let mut config = Config::default();
set_credential(&mut config, "not-a-provider", Some("x".to_string()));
assert!(stored_credential(&config, "not-a-provider").is_none());
assert!(!is_configured(&config, "not-a-provider"));
}
#[test]
fn claude_code_is_configured_by_its_flag_not_a_credential() {
let mut config = Config::default();
assert!(!is_configured(&config, "claude-code"));
set_credential(&mut config, "claude-code", Some("x".to_string()));
assert!(!is_configured(&config, "claude-code"));
config.providers.claude_code_enabled = true;
assert!(is_configured(&config, "claude-code"));
}
#[test]
fn redact_hides_short_keys_entirely() {
assert_eq!(redact(""), "****");
assert_eq!(redact("abc"), "****");
assert_eq!(redact("12345678"), "****");
}
#[test]
fn redact_shows_a_recognisable_suffix_of_a_long_key() {
assert_eq!(redact("sk-ant-api-key-12345"), "****2345");
assert_eq!(redact("123456789"), "****6789");
assert!(!redact("sk-ant-api-key-12345").contains("sk-ant"));
}
#[test]
fn redact_counts_characters_not_bytes() {
assert_eq!(redact("日本語日本語日本語"), "****語日本語");
assert_eq!(redact("日本語"), "****");
assert_eq!(redact("日本語日本語日本"), "****");
}
}