2026-07-12 11:28:39 +07:00
|
|
|
//! Application-level configuration: LLM providers, model roles, and defaults,
|
|
|
|
|
//! persisted to `app_config.json` in the store directory.
|
|
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Top-level application config: registered providers, named model roles,
|
|
|
|
|
/// and which provider/model to use by default.
|
2026-07-11 13:16:10 +07:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct AppConfig {
|
|
|
|
|
pub providers: HashMap<String, ProviderConfig>,
|
|
|
|
|
pub model_roles: HashMap<String, ModelRole>,
|
|
|
|
|
pub default_provider: String,
|
|
|
|
|
pub default_model: String,
|
2026-07-12 13:40:58 +07:00
|
|
|
pub default_context_window: u32,
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Connection details for a single LLM provider (base URL, API key source).
|
2026-07-11 13:16:10 +07:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct ProviderConfig {
|
|
|
|
|
pub api_base: String,
|
|
|
|
|
pub api_key_env: Option<String>,
|
|
|
|
|
pub default_model: Option<String>,
|
2026-07-12 02:19:08 +07:00
|
|
|
pub default_api_key: Option<String>,
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// A named role (e.g. "default") mapping to a specific provider/model and
|
|
|
|
|
/// its generation parameters.
|
2026-07-11 13:16:10 +07:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct ModelRole {
|
|
|
|
|
pub provider: String,
|
|
|
|
|
pub model: String,
|
|
|
|
|
pub max_tokens: Option<u32>,
|
2026-07-12 13:40:58 +07:00
|
|
|
pub context_window: Option<u32>,
|
2026-07-11 13:16:10 +07:00
|
|
|
pub temperature: Option<f32>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for AppConfig {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
let mut providers = HashMap::new();
|
2026-07-11 22:10:17 +07:00
|
|
|
providers.insert("zen".to_string(), ProviderConfig {
|
|
|
|
|
api_base: "https://opencode.ai/zen/v1".to_string(),
|
|
|
|
|
api_key_env: Some("API_KEY".to_string()),
|
|
|
|
|
default_model: Some("deepseek-v4-flash-free".to_string()),
|
2026-07-12 02:19:08 +07:00
|
|
|
default_api_key: None,
|
2026-07-11 13:16:10 +07:00
|
|
|
});
|
2026-07-12 01:43:57 +07:00
|
|
|
providers.insert("router".to_string(), ProviderConfig {
|
|
|
|
|
api_base: "https://9router.asepharyana.my.id/v1".to_string(),
|
2026-07-13 04:10:08 +07:00
|
|
|
api_key_env: Some("ROUTER_API_KEY".to_string()),
|
2026-07-12 01:43:57 +07:00
|
|
|
default_model: Some("claude-opus-4-8".to_string()),
|
2026-07-13 04:10:08 +07:00
|
|
|
default_api_key: None,
|
2026-07-12 01:43:57 +07:00
|
|
|
});
|
2026-07-11 13:16:10 +07:00
|
|
|
let mut model_roles = HashMap::new();
|
|
|
|
|
model_roles.insert("default".to_string(), ModelRole {
|
2026-07-11 22:10:17 +07:00
|
|
|
provider: "zen".to_string(),
|
|
|
|
|
model: "deepseek-v4-flash-free".to_string(),
|
2026-07-12 13:40:58 +07:00
|
|
|
max_tokens: None,
|
|
|
|
|
context_window: None,
|
2026-07-11 13:16:10 +07:00
|
|
|
temperature: Some(0.7),
|
|
|
|
|
});
|
|
|
|
|
AppConfig {
|
|
|
|
|
providers,
|
|
|
|
|
model_roles,
|
2026-07-11 22:10:17 +07:00
|
|
|
default_provider: "zen".to_string(),
|
|
|
|
|
default_model: "deepseek-v4-flash-free".to_string(),
|
2026-07-12 13:40:58 +07:00
|
|
|
default_context_window: 256_000,
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AppConfig {
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Load app config from disk, falling back to defaults on any failure.
|
|
|
|
|
///
|
|
|
|
|
/// Flow: read `<store>/app_config.json` → JSON-parse → on missing file
|
|
|
|
|
/// or parse error, use `Self::default()` → merge any default providers
|
|
|
|
|
/// not already present in the loaded config.
|
|
|
|
|
///
|
|
|
|
|
/// Why: the merge step lets newly-added default providers (e.g. a new
|
|
|
|
|
/// release adding a provider) appear even in configs saved by older
|
|
|
|
|
/// versions, without clobbering user-edited entries with the same name.
|
|
|
|
|
///
|
|
|
|
|
/// Return: a fully-populated `AppConfig`, never fails.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn load() -> Self {
|
|
|
|
|
let store = super::store::Store::new();
|
|
|
|
|
let path = store.base_dir.join("app_config.json");
|
2026-07-12 10:23:26 +07:00
|
|
|
let mut cfg: AppConfig = match std::fs::read_to_string(&path) {
|
|
|
|
|
Ok(s) => match serde_json::from_str(&s) {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => {
|
2026-07-12 10:57:32 +07:00
|
|
|
tracing::warn!(
|
2026-07-12 10:23:26 +07:00
|
|
|
"warning: failed to parse config file '{}': {}. Loading defaults.",
|
|
|
|
|
path.display(), e
|
|
|
|
|
);
|
|
|
|
|
Self::default()
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
Err(_) => Self::default(),
|
|
|
|
|
};
|
2026-07-12 02:21:08 +07:00
|
|
|
// Merge any default providers not present in the loaded config
|
|
|
|
|
let defaults = Self::default();
|
|
|
|
|
for (name, provider) in defaults.providers {
|
|
|
|
|
cfg.providers.entry(name).or_insert(provider);
|
|
|
|
|
}
|
2026-07-12 15:36:45 +07:00
|
|
|
// Auto-detect provider from ~/.claude/settings.json
|
|
|
|
|
if let Some(claude_provider) = detect_claude_settings_provider() {
|
|
|
|
|
cfg.providers.entry("claude".to_string()).or_insert(claude_provider);
|
2026-07-12 15:38:37 +07:00
|
|
|
// Register known Claude models as named model roles
|
|
|
|
|
let claude_models = [
|
|
|
|
|
("claude-opus-4-8", "claude-opus-4-8"),
|
|
|
|
|
("claude-sonnet-5", "claude-sonnet-5"),
|
|
|
|
|
("claude-haiku-4-5", "claude-haiku-4-5-20251001"),
|
|
|
|
|
];
|
|
|
|
|
for (role_name, model_name) in &claude_models {
|
|
|
|
|
cfg.model_roles.entry(role_name.to_string()).or_insert(ModelRole {
|
|
|
|
|
provider: "claude".to_string(),
|
|
|
|
|
model: model_name.to_string(),
|
|
|
|
|
max_tokens: Some(8192),
|
|
|
|
|
context_window: Some(200_000),
|
|
|
|
|
temperature: Some(0.7),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// Set as default provider only if user hasn't picked a custom default
|
2026-07-12 15:36:45 +07:00
|
|
|
if cfg.default_provider == defaults.default_provider {
|
|
|
|
|
cfg.default_provider = "claude".to_string();
|
2026-07-12 15:38:37 +07:00
|
|
|
cfg.default_model = "claude-opus-4-8".to_string();
|
2026-07-12 15:36:45 +07:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-12 02:21:08 +07:00
|
|
|
cfg
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-12 15:36:45 +07:00
|
|
|
|
|
|
|
|
/// Configuration structure inside `~/.claude/settings.json`.
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
struct ClaudeEnv {
|
|
|
|
|
#[serde(alias = "ANTHROPIC_BASE_URL")]
|
|
|
|
|
anthropic_base_url: Option<String>,
|
|
|
|
|
#[serde(alias = "ANTHROPIC_API_KEY")]
|
|
|
|
|
anthropic_api_key: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
struct ClaudeSettings {
|
|
|
|
|
env: Option<ClaudeEnv>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 01:06:36 +07:00
|
|
|
/// Return a `ProviderConfig` for the Claude provider, checking both
|
|
|
|
|
/// `~/.claude/settings.json` and the process environment.
|
|
|
|
|
///
|
|
|
|
|
/// Flow: try the file (`env.ANTHROPIC_BASE_URL` + `env.ANTHROPIC_API_KEY`)
|
|
|
|
|
/// first → fall back to the `ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY` env
|
|
|
|
|
/// vars → if neither source has both values, return `None`.
|
|
|
|
|
///
|
|
|
|
|
/// Why: Claude Code may inject credentials via env vars (OAuth session) rather
|
|
|
|
|
/// than through its settings file, so reading only the file misses them.
|
2026-07-12 15:36:45 +07:00
|
|
|
fn detect_claude_settings_provider() -> Option<ProviderConfig> {
|
2026-07-15 01:06:36 +07:00
|
|
|
// Prefer the file, then fall back to env vars.
|
2026-07-15 01:54:36 +07:00
|
|
|
let (base_url, key) = claude_credentials_from_file()
|
2026-07-15 01:06:36 +07:00
|
|
|
.or_else(claude_credentials_from_env)?;
|
2026-07-12 15:36:45 +07:00
|
|
|
Some(ProviderConfig {
|
|
|
|
|
api_base: base_url,
|
2026-07-15 01:54:36 +07:00
|
|
|
// Keep the env-var name so runtime env overrides still work.
|
2026-07-12 15:36:45 +07:00
|
|
|
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
|
|
|
|
|
default_model: None,
|
2026-07-15 01:54:36 +07:00
|
|
|
// Store the key read from the file as a direct fallback.
|
|
|
|
|
// Without this, subagent/engine.rs resolve_provider_config() falls
|
|
|
|
|
// through to std::env::var("ANTHROPIC_API_KEY") which is only
|
|
|
|
|
// injected into the Claude Code process — not into zesdex. Workflow
|
|
|
|
|
// nodes therefore got an empty key and failed with
|
|
|
|
|
// "no API key configured for provider 'claude'", even though the
|
|
|
|
|
// main agent succeeded (it has a DEFAULT_API_KEY fallback that
|
|
|
|
|
// subagents intentionally do not have).
|
|
|
|
|
default_api_key: Some(key),
|
2026-07-12 15:36:45 +07:00
|
|
|
})
|
|
|
|
|
}
|
2026-07-15 01:06:36 +07:00
|
|
|
|
|
|
|
|
/// Try to read Claude credentials from `~/.claude/settings.json`'s `env` block.
|
|
|
|
|
fn claude_credentials_from_file() -> Option<(String, String)> {
|
|
|
|
|
let path = dirs::home_dir()?.join(".claude").join("settings.json");
|
|
|
|
|
let content = std::fs::read_to_string(&path).ok()?;
|
|
|
|
|
let settings: ClaudeSettings = serde_json::from_str(&content).ok()?;
|
|
|
|
|
let env = settings.env?;
|
|
|
|
|
let base_url = env.anthropic_base_url?;
|
|
|
|
|
let key = env.anthropic_api_key?;
|
|
|
|
|
Some((base_url, key))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Try to read Claude credentials from `ANTHROPIC_BASE_URL` /
|
|
|
|
|
/// `ANTHROPIC_API_KEY` environment variables.
|
|
|
|
|
fn claude_credentials_from_env() -> Option<(String, String)> {
|
|
|
|
|
let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?;
|
|
|
|
|
let key = std::env::var("ANTHROPIC_API_KEY").ok()?;
|
|
|
|
|
Some((base_url, key))
|
|
|
|
|
}
|
2026-07-15 01:42:15 +07:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn claude_credentials_from_env_resolves_real_env_vars() {
|
|
|
|
|
// In the test runner's environment ANTHROPIC_BASE_URL and
|
|
|
|
|
// ANTHROPIC_API_KEY may or may not be set — we only verify that
|
|
|
|
|
// the function returns Some(..) when both are present.
|
|
|
|
|
let (b, k) = match claude_credentials_from_env() {
|
|
|
|
|
Some(v) => v,
|
|
|
|
|
None => {
|
|
|
|
|
// Not an error: CI / local without the vars.
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
assert!(!b.is_empty(), "ANTHROPIC_BASE_URL must not be empty");
|
|
|
|
|
assert!(!k.is_empty(), "ANTHROPIC_API_KEY must not be empty");
|
|
|
|
|
}
|
|
|
|
|
}
|