2026-07-20 09:04:57 +07:00
|
|
|
|
//! JSON file–backed `AppConfigRepository` with Claude credential auto-detection.
|
2026-07-16 12:32:17 +07:00
|
|
|
|
|
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
2026-08-27 22:10:28 +07:00
|
|
|
|
use zesdex_domain::cms::{
|
|
|
|
|
|
AppConfig, AppConfigRepository, ModelRole, ProviderConfig, RepositoryError,
|
|
|
|
|
|
};
|
2026-07-16 12:32:17 +07:00
|
|
|
|
|
2026-07-20 09:04:57 +07:00
|
|
|
|
use crate::utils::write_json_atomic;
|
2026-07-16 12:32:17 +07:00
|
|
|
|
|
2026-07-19 17:05:27 +07:00
|
|
|
|
/// File-based `AppConfigRepository` that reads/writes `app_config.json`.
|
2026-07-16 12:32:17 +07:00
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
|
|
|
|
pub struct JsonAppConfigRepository;
|
|
|
|
|
|
|
|
|
|
|
|
impl JsonAppConfigRepository {
|
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
|
Self
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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-20 14:30:36 +07:00
|
|
|
|
#[serde(alias = "customModel", alias = "model")]
|
|
|
|
|
|
custom_model: Option<String>,
|
2026-07-16 12:32:17 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 14:30:36 +07:00
|
|
|
|
fn claude_settings_from_file() -> Option<ClaudeSettings> {
|
2026-07-16 12:32:17 +07:00
|
|
|
|
let path = dirs::home_dir()?.join(".claude").join("settings.json");
|
|
|
|
|
|
let content = std::fs::read_to_string(&path).ok()?;
|
2026-07-20 14:30:36 +07:00
|
|
|
|
serde_json::from_str(&content).ok()
|
2026-07-16 12:32:17 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 14:30:36 +07:00
|
|
|
|
fn detect_claude_settings_provider() -> Option<(ProviderConfig, Option<String>)> {
|
|
|
|
|
|
let settings = claude_settings_from_file();
|
2026-08-27 22:10:28 +07:00
|
|
|
|
|
2026-07-20 14:30:36 +07:00
|
|
|
|
let file_creds = settings.as_ref().and_then(|s| {
|
|
|
|
|
|
let env = s.env.as_ref()?;
|
2026-08-27 22:10:28 +07:00
|
|
|
|
Some((
|
|
|
|
|
|
env.anthropic_base_url.clone()?,
|
|
|
|
|
|
env.anthropic_api_key.clone()?,
|
|
|
|
|
|
))
|
2026-07-20 14:30:36 +07:00
|
|
|
|
});
|
2026-08-27 22:10:28 +07:00
|
|
|
|
|
2026-07-20 14:30:36 +07:00
|
|
|
|
let env_creds = || -> 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-16 12:32:17 +07:00
|
|
|
|
|
2026-07-20 14:30:36 +07:00
|
|
|
|
let custom_model = settings.and_then(|s| s.custom_model);
|
|
|
|
|
|
|
|
|
|
|
|
let (base_url, key) = file_creds.or_else(env_creds)?;
|
2026-08-27 22:10:28 +07:00
|
|
|
|
|
2026-07-20 14:30:36 +07:00
|
|
|
|
Some((
|
|
|
|
|
|
ProviderConfig {
|
|
|
|
|
|
api_base: base_url,
|
|
|
|
|
|
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
|
|
|
|
|
|
default_model: custom_model.clone(),
|
|
|
|
|
|
default_api_key: Some(key),
|
|
|
|
|
|
},
|
2026-08-27 22:10:28 +07:00
|
|
|
|
custom_model,
|
2026-07-20 14:30:36 +07:00
|
|
|
|
))
|
2026-07-16 12:32:17 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 23:45:22 +07:00
|
|
|
|
/// Apply a detected Claude provider + custom model onto an `AppConfig`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Pure (no I/O) so it can be unit-tested. Flow:
|
|
|
|
|
|
/// 1. Always `insert`s the "claude" provider (refreshing a possibly stale
|
|
|
|
|
|
/// persisted entry with the current base URL + key from settings.json).
|
|
|
|
|
|
/// 2. Registers known Claude model roles if missing.
|
|
|
|
|
|
/// 3. Always sets `default_provider = "claude"` and
|
|
|
|
|
|
/// `default_model = custom_model.unwrap_or("claude-opus-4-8")` so Opus
|
|
|
|
|
|
/// is the default whenever `~/.claude/settings.json` is present.
|
|
|
|
|
|
fn apply_claude_provider(
|
|
|
|
|
|
cfg: &mut AppConfig,
|
|
|
|
|
|
claude_provider: ProviderConfig,
|
|
|
|
|
|
custom_model: Option<String>,
|
|
|
|
|
|
) {
|
|
|
|
|
|
cfg.providers.insert("claude".to_string(), claude_provider);
|
|
|
|
|
|
|
|
|
|
|
|
let claude_models: [(&str, &str); 3] = [
|
|
|
|
|
|
("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),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if let Some(custom) = &custom_model {
|
|
|
|
|
|
cfg.model_roles.entry(custom.clone()).or_insert(ModelRole {
|
|
|
|
|
|
provider: "claude".to_string(),
|
|
|
|
|
|
model: custom.clone(),
|
|
|
|
|
|
max_tokens: Some(8192),
|
|
|
|
|
|
context_window: Some(200_000),
|
|
|
|
|
|
temperature: Some(0.7),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Always prefer the Claude provider + Opus model when settings.json
|
|
|
|
|
|
// is present — this is the user's explicit custom endpoint choice.
|
|
|
|
|
|
cfg.default_provider = "claude".to_string();
|
|
|
|
|
|
cfg.default_model = custom_model.unwrap_or_else(|| "claude-opus-4-8".to_string());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 12:32:17 +07:00
|
|
|
|
impl AppConfigRepository for JsonAppConfigRepository {
|
2026-07-20 06:14:23 +07:00
|
|
|
|
fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError> {
|
2026-07-16 12:32:17 +07:00
|
|
|
|
let path = base_dir.join("app_config.json");
|
|
|
|
|
|
let mut cfg: AppConfig = match std::fs::read_to_string(&path) {
|
2026-07-20 06:14:23 +07:00
|
|
|
|
Ok(s) => serde_json::from_str(&s)?,
|
2026-08-27 22:10:28 +07:00
|
|
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => AppConfig::default(),
|
2026-07-20 09:04:57 +07:00
|
|
|
|
Err(e) => return Err(RepositoryError::Io(e)),
|
2026-07-16 12:32:17 +07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let defaults = AppConfig::default();
|
|
|
|
|
|
for (name, provider) in defaults.providers {
|
|
|
|
|
|
cfg.providers.entry(name).or_insert(provider);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 14:30:36 +07:00
|
|
|
|
if let Some((claude_provider, custom_model)) = detect_claude_settings_provider() {
|
2026-08-27 23:45:22 +07:00
|
|
|
|
apply_claude_provider(&mut cfg, claude_provider, custom_model);
|
2026-07-16 12:32:17 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(cfg)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 06:14:23 +07:00
|
|
|
|
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError> {
|
|
|
|
|
|
std::fs::create_dir_all(base_dir)?;
|
2026-07-16 12:32:17 +07:00
|
|
|
|
let path = base_dir.join("app_config.json");
|
2026-07-20 06:39:30 +07:00
|
|
|
|
write_json_atomic(&path, config, None)?;
|
2026-07-16 12:32:17 +07:00
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-27 23:45:22 +07:00
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
|
|
|
|
|
|
|
fn claude_provider(base: &str, key: Option<&str>) -> ProviderConfig {
|
|
|
|
|
|
ProviderConfig {
|
|
|
|
|
|
api_base: base.to_string(),
|
|
|
|
|
|
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
|
|
|
|
|
|
default_model: Some("claude-opus-4-8".to_string()),
|
|
|
|
|
|
default_api_key: key.map(|s| s.to_string()),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn claude_settings_parse_env() {
|
|
|
|
|
|
let parsed: ClaudeSettings = serde_json::from_str(
|
|
|
|
|
|
r#"{"env":{"ANTHROPIC_BASE_URL":"https://9router.example/v1","ANTHROPIC_API_KEY":"sk-test"}}"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let env = parsed.env.unwrap();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
env.anthropic_base_url.as_deref(),
|
|
|
|
|
|
Some("https://9router.example/v1")
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(env.anthropic_api_key.as_deref(), Some("sk-test"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn apply_claude_refreshes_stale_provider_and_sets_opus_default() {
|
|
|
|
|
|
// Simulate a previously-persisted app_config.json with a STALE claude
|
|
|
|
|
|
// provider + non-opus default (e.g. user had switched provider).
|
|
|
|
|
|
let mut cfg = AppConfig {
|
|
|
|
|
|
providers: {
|
|
|
|
|
|
let mut m = HashMap::new();
|
|
|
|
|
|
m.insert(
|
|
|
|
|
|
"claude".to_string(),
|
|
|
|
|
|
claude_provider("https://old.example/v1", Some("sk-old")),
|
|
|
|
|
|
);
|
|
|
|
|
|
m
|
|
|
|
|
|
},
|
|
|
|
|
|
model_roles: HashMap::new(),
|
|
|
|
|
|
default_provider: "router".to_string(),
|
|
|
|
|
|
default_model: "other-model".to_string(),
|
|
|
|
|
|
default_context_window: 256_000,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Detect returned a fresh provider from ~/.claude/settings.json.
|
|
|
|
|
|
apply_claude_provider(
|
|
|
|
|
|
&mut cfg,
|
|
|
|
|
|
claude_provider("https://9router.example/v1", Some("sk-new")),
|
|
|
|
|
|
None,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let claude = cfg.providers.get("claude").unwrap();
|
|
|
|
|
|
assert_eq!(claude.api_base, "https://9router.example/v1");
|
|
|
|
|
|
assert_eq!(claude.default_api_key.as_deref(), Some("sk-new"));
|
|
|
|
|
|
// Insert (not or_insert) → stale entry refreshed.
|
|
|
|
|
|
assert_eq!(cfg.default_provider, "claude");
|
|
|
|
|
|
assert_eq!(cfg.default_model, "claude-opus-4-8");
|
|
|
|
|
|
|
|
|
|
|
|
// Claude model roles registered.
|
|
|
|
|
|
assert!(cfg.model_roles.contains_key("claude-opus-4-8"));
|
|
|
|
|
|
assert!(cfg.model_roles.contains_key("claude-sonnet-5"));
|
|
|
|
|
|
assert!(cfg.model_roles.contains_key("claude-haiku-4-5"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn apply_claude_honors_custom_model_from_settings() {
|
|
|
|
|
|
let mut cfg = AppConfig::default();
|
|
|
|
|
|
apply_claude_provider(
|
|
|
|
|
|
&mut cfg,
|
|
|
|
|
|
claude_provider("https://9router.example/v1", Some("sk-new")),
|
|
|
|
|
|
Some("claude-opus-4-8".to_string()),
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(cfg.default_model, "claude-opus-4-8");
|
|
|
|
|
|
assert!(cfg.model_roles.contains_key("claude-opus-4-8"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn detect_uses_env_creds_as_fallback() {
|
|
|
|
|
|
// When ~/.claude/settings.json is absent/unreadable, the env-var
|
|
|
|
|
|
// fallback should produce a "claude" provider. Set env vars, call
|
|
|
|
|
|
// detect, and assert the resulting provider uses them.
|
|
|
|
|
|
std::env::set_var("ANTHROPIC_BASE_URL", "https://env.example/v1");
|
|
|
|
|
|
std::env::set_var("ANTHROPIC_API_KEY", "sk-env");
|
|
|
|
|
|
match detect_claude_settings_provider() {
|
|
|
|
|
|
Some((provider, _custom)) => {
|
|
|
|
|
|
// If the real settings.json exists it wins (base could be the
|
|
|
|
|
|
// real 9router URL); otherwise env creds are used. Either way,
|
|
|
|
|
|
// the provider must have api_key_env pointing at ANTHROPIC_API_KEY.
|
|
|
|
|
|
assert_eq!(provider.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
|
|
|
|
|
|
}
|
|
|
|
|
|
None => {
|
|
|
|
|
|
// No file + no env (shouldn't happen since we just set env).
|
|
|
|
|
|
panic!("expected env fallback to produce a provider");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
std::env::remove_var("ANTHROPIC_BASE_URL");
|
|
|
|
|
|
std::env::remove_var("ANTHROPIC_API_KEY");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|