Files
zesdex/src/model/app_config.rs
T
asepharyana 1c08b8e4e9 feat: Implement lesson generation feature and update status display
- Added functionality to generate lessons based on code reviews, including prompts and instructions for the reviewer.
- Updated the `.gitignore` to exclude lesson documentation files.
- Removed the `/lesson` command from the help menu and command parsing.
- Enhanced the status bar to display a message when a lesson is being generated.
- Introduced a new `lesson_running` state to track lesson generation progress.
- Updated various components to handle the new lesson generation workflow, including subagent events and token usage tracking.
2026-07-15 03:11:55 +07:00

212 lines
8.4 KiB
Rust

//! Application-level configuration: LLM providers, model roles, and defaults,
//! persisted to `app_config.json` in the store directory.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Top-level application config: registered providers, named model roles,
/// and which provider/model to use by default.
#[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,
pub default_context_window: u32,
}
/// Connection details for a single LLM provider (base URL, API key source).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
pub api_base: String,
pub api_key_env: Option<String>,
pub default_model: Option<String>,
pub default_api_key: Option<String>,
}
/// A named role (e.g. "default") mapping to a specific provider/model and
/// its generation parameters.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelRole {
pub provider: String,
pub model: String,
pub max_tokens: Option<u32>,
pub context_window: Option<u32>,
pub temperature: Option<f32>,
}
impl Default for AppConfig {
fn default() -> Self {
let mut providers = HashMap::new();
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()),
default_api_key: None,
});
providers.insert("router".to_string(), ProviderConfig {
api_base: "https://9router.asepharyana.my.id/v1".to_string(),
api_key_env: Some("ROUTER_API_KEY".to_string()),
default_model: Some("claude-opus-4-8".to_string()),
default_api_key: None,
});
let mut model_roles = HashMap::new();
model_roles.insert("default".to_string(), ModelRole {
provider: "zen".to_string(),
model: "deepseek-v4-flash-free".to_string(),
max_tokens: None,
context_window: None,
temperature: Some(0.7),
});
AppConfig {
providers,
model_roles,
default_provider: "zen".to_string(),
default_model: "deepseek-v4-flash-free".to_string(),
default_context_window: 256_000,
}
}
}
impl AppConfig {
/// 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.
pub fn load() -> Self {
let store = super::store::Store::new();
let path = store.base_dir.join("app_config.json");
let mut cfg: AppConfig = match std::fs::read_to_string(&path) {
Ok(s) => match serde_json::from_str(&s) {
Ok(c) => c,
Err(e) => {
tracing::warn!(
"warning: failed to parse config file '{}': {}. Loading defaults.",
path.display(), e
);
Self::default()
}
},
Err(_) => Self::default(),
};
// 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);
}
// 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);
// 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
if cfg.default_provider == defaults.default_provider {
cfg.default_provider = "claude".to_string();
cfg.default_model = "claude-opus-4-8".to_string();
}
}
cfg
}
}
/// 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>,
}
/// 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.
fn detect_claude_settings_provider() -> Option<ProviderConfig> {
// Prefer the file, then fall back to env vars.
let (base_url, key) = claude_credentials_from_file()
.or_else(claude_credentials_from_env)?;
Some(ProviderConfig {
api_base: base_url,
// Keep the env-var name so runtime env overrides still work.
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
default_model: None,
// 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),
})
}
/// 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))
}
#[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 Some((b, k)) = claude_credentials_from_env() else {
// 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");
}
}