Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,10 @@ docker run -d \
ghcr.io/moltis-org/moltis:latest
```

Open `https://localhost:13131` and complete the setup. See [Docker docs](https://docs.moltis.org/docker.html) for Podman, OrbStack, TLS trust, and persistence details.
Open `https://localhost:13131` and complete the setup. For unattended Docker
deployments, set `MOLTIS_PASSWORD`, `MOLTIS_PROVIDER`, and `MOLTIS_API_KEY`
before first boot to skip the setup wizard. See [Docker docs](https://docs.moltis.org/docker.html)
for Podman, OrbStack, TLS trust, and persistence details.

### Cloud Deployment

Expand Down
5 changes: 5 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod env_subst;
pub mod error;
pub mod loader;
pub mod migrate;
pub mod provider_env;
pub mod schema;
pub mod template;
pub mod validate;
Expand All @@ -32,6 +33,10 @@ pub use {
set_share_dir, share_dir, soul_path, tools_path, update_config, user_global_config_dir,
user_global_config_dir_if_different, user_path,
},
provider_env::{
GenericProviderEnv, env_value_with_overrides, generic_provider_api_key_from_env,
generic_provider_env, generic_provider_env_source_for_provider, normalize_provider_name,
},
schema::{
AgentIdentity, AgentPreset, AgentsConfig, AuthConfig, CalDavAccountConfig, CalDavConfig,
ChatConfig, GeoLocation, MemoryScope, MessageQueueMode, MoltisConfig, PresetMemoryConfig,
Expand Down
194 changes: 194 additions & 0 deletions crates/config/src/provider_env.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
use std::collections::HashMap;

use secrecy::Secret;

const PROVIDER_ENV_CANDIDATES: &[&str] = &["MOLTIS_PROVIDER", "PROVIDER"];
const API_KEY_ENV_CANDIDATES: &[&str] = &["MOLTIS_API_KEY", "API_KEY"];

#[derive(Clone)]
pub struct GenericProviderEnv {
pub provider: String,
pub provider_var: &'static str,
pub api_key: Secret<String>,
pub api_key_var: &'static str,
}

fn non_empty_env_value(value: String) -> Option<String> {
(!value.trim().is_empty()).then_some(value)
}

fn env_value_from_source<F>(
env_overrides: &HashMap<String, String>,
key: &str,
env_lookup: F,
) -> Option<String>
where
F: FnOnce(&str) -> Option<String>,
{
env_overrides
.get(key)
.cloned()
.and_then(non_empty_env_value)
.or_else(|| env_lookup(key).and_then(non_empty_env_value))
}

pub fn env_value_with_overrides(
env_overrides: &HashMap<String, String>,
key: &str,
) -> Option<String> {
env_value_from_source(env_overrides, key, |env_key| std::env::var(env_key).ok())
}

/// Resolve the generic provider selector and API key from environment variables.
///
/// `MOLTIS_*` keys win over the bare aliases, but provider and API key are resolved
/// independently so mixed pairs such as `MOLTIS_PROVIDER` + `API_KEY` are accepted.
/// The concrete variable names used are returned for diagnostics and UI source labels.
pub fn generic_provider_env(env_overrides: &HashMap<String, String>) -> Option<GenericProviderEnv> {
let (provider_var, provider_raw) = PROVIDER_ENV_CANDIDATES
.iter()
.find_map(|key| env_value_with_overrides(env_overrides, key).map(|value| (*key, value)))?;
let (api_key_var, api_key) = API_KEY_ENV_CANDIDATES
.iter()
.find_map(|key| env_value_with_overrides(env_overrides, key).map(|value| (*key, value)))?;

Some(GenericProviderEnv {
provider: normalize_provider_name(&provider_raw)?,
provider_var,
api_key: Secret::new(api_key),
api_key_var,
})
}

pub fn generic_provider_api_key_from_env(
provider: &str,
env_overrides: &HashMap<String, String>,
) -> Option<Secret<String>> {
let normalized_provider = normalize_provider_name(provider)?;
let generic = generic_provider_env(env_overrides)?;
(generic.provider == normalized_provider).then_some(generic.api_key)
}

pub fn generic_provider_env_source_for_provider(
provider: &str,
env_overrides: &HashMap<String, String>,
) -> Option<String> {
let normalized_provider = normalize_provider_name(provider)?;
let generic = generic_provider_env(env_overrides)?;
(generic.provider == normalized_provider)
.then(|| format!("env:{}+{}", generic.provider_var, generic.api_key_var))
}

pub fn normalize_provider_name(value: &str) -> Option<String> {
let normalized = value.trim().to_ascii_lowercase().replace('_', "-");
if normalized.is_empty() {
return None;
}

let canonical = match normalized.as_str() {
"claude" => "anthropic",
"google" | "google-gemini" => "gemini",
"grok" => "xai",
"local" => "local-llm",
"z-ai" | "z.ai" | "zhipu" | "zhipu-ai" => "zai",
other => other,
};

Some(canonical.to_string())
}

#[cfg(test)]
mod tests {
use {super::*, secrecy::ExposeSecret};

#[test]
fn env_value_with_overrides_prefers_overrides() {
let env_overrides =
HashMap::from([("MOLTIS_API_KEY".to_string(), "override-key".to_string())]);

assert_eq!(
env_value_from_source(&env_overrides, "MOLTIS_API_KEY", |_| Some(
"ambient-key".to_string()
))
.as_deref(),
Some("override-key")
);
}

#[test]
fn generic_provider_env_prefers_namespaced_keys() {
let env_overrides = HashMap::from([
("PROVIDER".to_string(), "anthropic".to_string()),
("API_KEY".to_string(), "fallback-key".to_string()),
("MOLTIS_PROVIDER".to_string(), "openai".to_string()),
("MOLTIS_API_KEY".to_string(), "primary-key".to_string()),
]);

let Some(resolved) = generic_provider_env(&env_overrides) else {
panic!("generic provider env should resolve");
};
assert_eq!(resolved.provider, "openai");
assert_eq!(resolved.provider_var, "MOLTIS_PROVIDER");
assert_eq!(resolved.api_key.expose_secret(), "primary-key");
assert_eq!(resolved.api_key_var, "MOLTIS_API_KEY");
}

#[test]
fn generic_provider_env_normalizes_common_aliases() {
let env_overrides = HashMap::from([
("PROVIDER".to_string(), "google".to_string()),
("API_KEY".to_string(), "test-key".to_string()),
]);

let Some(resolved) = generic_provider_env(&env_overrides) else {
panic!("generic provider env should resolve");
};
assert_eq!(resolved.provider, "gemini");
}

#[test]
fn generic_provider_env_accepts_mixed_namespace_pairs() {
let env_overrides = HashMap::from([
("MOLTIS_PROVIDER".to_string(), "openai".to_string()),
("API_KEY".to_string(), "test-key".to_string()),
]);

let Some(resolved) = generic_provider_env(&env_overrides) else {
panic!("generic provider env should resolve");
};
assert_eq!(resolved.provider, "openai");
assert_eq!(resolved.provider_var, "MOLTIS_PROVIDER");
assert_eq!(resolved.api_key.expose_secret(), "test-key");
assert_eq!(resolved.api_key_var, "API_KEY");
}

#[test]
fn generic_provider_api_key_matches_only_selected_provider() {
let env_overrides = HashMap::from([
("MOLTIS_PROVIDER".to_string(), "openai".to_string()),
("MOLTIS_API_KEY".to_string(), "sk-test".to_string()),
]);

assert_eq!(
generic_provider_api_key_from_env("openai", &env_overrides)
.as_ref()
.map(ExposeSecret::expose_secret)
.map(|value| value.as_str()),
Some("sk-test")
);
assert!(generic_provider_api_key_from_env("anthropic", &env_overrides).is_none());
}

#[test]
fn generic_provider_source_reports_actual_env_keys() {
let env_overrides = HashMap::from([
("PROVIDER".to_string(), "anthropic".to_string()),
("API_KEY".to_string(), "sk-test".to_string()),
]);

assert_eq!(
generic_provider_env_source_for_provider("anthropic", &env_overrides).as_deref(),
Some("env:PROVIDER+API_KEY")
);
}
}
96 changes: 86 additions & 10 deletions crates/provider-setup/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1036,19 +1036,11 @@ fn set_provider_enabled_in_config(provider: &str, enabled: bool) -> ServiceResul
}

fn normalize_provider_name(value: &str) -> String {
value.trim().to_ascii_lowercase()
moltis_config::normalize_provider_name(value).unwrap_or_default()
}

fn env_value_with_overrides(env_overrides: &HashMap<String, String>, key: &str) -> Option<String> {
std::env::var(key)
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
env_overrides
.get(key)
.cloned()
.filter(|value| !value.trim().is_empty())
})
moltis_config::env_value_with_overrides(env_overrides, key)
}

fn ui_offered_provider_order(config: &ProvidersConfig) -> Vec<String> {
Expand Down Expand Up @@ -1116,6 +1108,14 @@ pub fn detect_auto_provider_sources_with_overrides(
{
sources.push(format!("env:{env_key}"));
}
if provider.auth_type == AuthType::ApiKey
&& let Some(source) = moltis_config::generic_provider_env_source_for_provider(
provider.name,
env_overrides,
)
{
sources.push(source);
}

if config
.get(provider.name)
Expand Down Expand Up @@ -1422,6 +1422,12 @@ impl LiveProviderSetupService {
{
return true;
}
if provider.auth_type == AuthType::ApiKey
&& moltis_config::generic_provider_api_key_from_env(provider.name, &self.env_overrides)
.is_some()
{
return true;
}
// Check config file
if let Some(entry) = active_config.get(provider.name)
&& entry
Expand Down Expand Up @@ -3391,6 +3397,35 @@ mod tests {
assert!(first.get("uiOrder").is_some());
}

#[tokio::test]
async fn available_marks_provider_configured_from_generic_provider_env() {
let registry = Arc::new(RwLock::new(ProviderRegistry::from_env_with_config(
&ProvidersConfig::default(),
)));
let svc = LiveProviderSetupService::new(registry, ProvidersConfig::default(), None)
.with_env_overrides(HashMap::from([
("MOLTIS_PROVIDER".to_string(), "openai".to_string()),
(
"MOLTIS_API_KEY".to_string(),
"sk-test-openai-generic".to_string(),
),
]));

let result = svc.available().await.unwrap();
let arr = result
.as_array()
.expect("providers.available should return array");
let openai = arr
.iter()
.find(|provider| provider.get("name").and_then(|v| v.as_str()) == Some("openai"))
.expect("openai should be present");

assert_eq!(
openai.get("configured").and_then(|v| v.as_bool()),
Some(true)
);
}

#[tokio::test]
async fn available_hides_unconfigured_providers_not_in_offered_list() {
let registry = Arc::new(RwLock::new(ProviderRegistry::from_env_with_config(
Expand Down Expand Up @@ -3457,6 +3492,31 @@ mod tests {
);
}

#[tokio::test]
async fn available_accepts_offered_provider_aliases() {
let registry = Arc::new(RwLock::new(ProviderRegistry::from_env_with_config(
&ProvidersConfig::default(),
)));
let config = ProvidersConfig {
offered: vec!["claude".into()],
..ProvidersConfig::default()
};
let svc = LiveProviderSetupService::new(registry, config, None);
let result = svc.available().await.unwrap();
let arr = result
.as_array()
.expect("providers.available should return array");
let names: Vec<&str> = arr
.iter()
.filter_map(|v| v.get("name").and_then(|n| n.as_str()))
.collect();

assert!(
names.contains(&"anthropic"),
"anthropic should be visible when offered contains alias 'claude', got: {names:?}"
);
}

#[tokio::test]
async fn available_hides_configured_provider_outside_offered() {
let registry = Arc::new(RwLock::new(ProviderRegistry::from_env_with_config(
Expand Down Expand Up @@ -4093,6 +4153,22 @@ mod tests {
assert!(has_explicit_provider_settings(&model_only));
}

#[test]
fn detect_auto_provider_sources_includes_generic_provider_env() {
let detected = detect_auto_provider_sources_with_overrides(
&ProvidersConfig::default(),
None,
&HashMap::from([
("PROVIDER".to_string(), "openai".to_string()),
("API_KEY".to_string(), "sk-test-openai-generic".to_string()),
]),
);

assert!(detected.iter().any(|source| {
source.provider == "openai" && source.source == "env:PROVIDER+API_KEY"
}));
}

#[tokio::test]
async fn validate_key_rejects_unknown_provider() {
let registry = Arc::new(RwLock::new(ProviderRegistry::from_env_with_config(
Expand Down
Loading
Loading