Model terbaru di 9router adalah claude-opus-5. Update semua jalur model default Opus: - fix(app_config_repo): fallback default_model custom_model.unwrap_or -> claude-opus-5; model_roles list claude-opus-5 - fix(app_config): router provider default_model -> claude-opus-5 - fix(settings test): assertion claude-opus-5 - fix(data): ~/.local/share/zesdex/settings.json model -> claude-opus-5
255 lines
9.0 KiB
Rust
255 lines
9.0 KiB
Rust
//! JSON file–backed `AppConfigRepository` with Claude credential auto-detection.
|
||
|
||
use std::path::Path;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use zesdex_domain::cms::{
|
||
AppConfig, AppConfigRepository, ModelRole, ProviderConfig, RepositoryError,
|
||
};
|
||
|
||
use crate::utils::write_json_atomic;
|
||
|
||
/// File-based `AppConfigRepository` that reads/writes `app_config.json`.
|
||
#[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>,
|
||
#[serde(alias = "customModel", alias = "model")]
|
||
custom_model: Option<String>,
|
||
}
|
||
|
||
fn claude_settings_from_file() -> Option<ClaudeSettings> {
|
||
let path = dirs::home_dir()?.join(".claude").join("settings.json");
|
||
let content = std::fs::read_to_string(&path).ok()?;
|
||
serde_json::from_str(&content).ok()
|
||
}
|
||
|
||
fn detect_claude_settings_provider() -> Option<(ProviderConfig, Option<String>)> {
|
||
let settings = claude_settings_from_file();
|
||
|
||
let file_creds = settings.as_ref().and_then(|s| {
|
||
let env = s.env.as_ref()?;
|
||
Some((
|
||
env.anthropic_base_url.clone()?,
|
||
env.anthropic_api_key.clone()?,
|
||
))
|
||
});
|
||
|
||
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))
|
||
};
|
||
|
||
let custom_model = settings.and_then(|s| s.custom_model);
|
||
|
||
let (base_url, key) = file_creds.or_else(env_creds)?;
|
||
|
||
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),
|
||
},
|
||
custom_model,
|
||
))
|
||
}
|
||
|
||
/// 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-5")` 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-5", "claude-opus-5"),
|
||
("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-5".to_string());
|
||
}
|
||
|
||
impl AppConfigRepository for JsonAppConfigRepository {
|
||
fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError> {
|
||
let path = base_dir.join("app_config.json");
|
||
let mut cfg: AppConfig = match std::fs::read_to_string(&path) {
|
||
Ok(s) => serde_json::from_str(&s)?,
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => AppConfig::default(),
|
||
Err(e) => return Err(RepositoryError::Io(e)),
|
||
};
|
||
|
||
let defaults = AppConfig::default();
|
||
for (name, provider) in defaults.providers {
|
||
cfg.providers.entry(name).or_insert(provider);
|
||
}
|
||
|
||
if let Some((claude_provider, custom_model)) = detect_claude_settings_provider() {
|
||
apply_claude_provider(&mut cfg, claude_provider, custom_model);
|
||
}
|
||
|
||
Ok(cfg)
|
||
}
|
||
|
||
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError> {
|
||
std::fs::create_dir_all(base_dir)?;
|
||
let path = base_dir.join("app_config.json");
|
||
write_json_atomic(&path, config, None)?;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
#[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-5".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-5");
|
||
|
||
// Claude model roles registered.
|
||
assert!(cfg.model_roles.contains_key("claude-opus-5"));
|
||
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-5".to_string()),
|
||
);
|
||
assert_eq!(cfg.default_model, "claude-opus-5");
|
||
assert!(cfg.model_roles.contains_key("claude-opus-5"));
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
}
|