From 1f91b447080e7106201d77585edb7d38b74e34bb Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 27 Aug 2026 23:45:22 +0700 Subject: [PATCH] fix(api): model Opus pakai URL + API custom dari ~/.claude/settings.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perbaiki provider claude agar selalu refresh dari settings.json dan menjadi default (claude-opus-4-8) setiap startup: - fix(app_config_repo): ganti or_insert -> insert untuk provider claude — base_url/key dari ~/.claude/settings.json selalu di-refresh, tidak tertutup snapshot lama app_config.json. - fix(app_config_repo): hapus kondisi default_provider == default — saat settings.json terdeteksi, default_provider='claude' dan default_model='claude-opus-4-8' SELALU di-set (sebelumnya skip kalau user pernah ganti provider). - fix(subagent/provider): resolve_subagent_provider fallback ke app_config.default_provider/default_model kalau settings.provider/model kosong — subagent ikut pakai Opus. - test: 4 unit test (parse settings.json, refresh stale provider, custom model, env fallback). Verified live: settings.json terbaca (9router URL + key). --- .../src/persistence/cms/app_config_repo.rs | 188 ++++++++++++++---- apps/infrastructure/src/subagent/provider.rs | 13 +- 2 files changed, 163 insertions(+), 38 deletions(-) diff --git a/apps/infrastructure/src/persistence/cms/app_config_repo.rs b/apps/infrastructure/src/persistence/cms/app_config_repo.rs index aebd484..0d023bb 100644 --- a/apps/infrastructure/src/persistence/cms/app_config_repo.rs +++ b/apps/infrastructure/src/persistence/cms/app_config_repo.rs @@ -72,6 +72,55 @@ fn detect_claude_settings_provider() -> Option<(ProviderConfig, Option)> )) } +/// 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, +) { + 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()); +} + impl AppConfigRepository for JsonAppConfigRepository { fn load(&self, base_dir: &Path) -> Result { let path = base_dir.join("app_config.json"); @@ -87,41 +136,7 @@ impl AppConfigRepository for JsonAppConfigRepository { } if let Some((claude_provider, custom_model)) = detect_claude_settings_provider() { - cfg.providers - .entry("claude".to_string()) - .or_insert(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), - }); - } - - if cfg.default_provider == defaults.default_provider { - cfg.default_provider = "claude".to_string(); - cfg.default_model = custom_model.unwrap_or_else(|| "claude-opus-4-8".to_string()); - } + apply_claude_provider(&mut cfg, claude_provider, custom_model); } Ok(cfg) @@ -134,3 +149,106 @@ impl AppConfigRepository for JsonAppConfigRepository { 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-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"); + } +} diff --git a/apps/infrastructure/src/subagent/provider.rs b/apps/infrastructure/src/subagent/provider.rs index 5c7eefe..1657490 100644 --- a/apps/infrastructure/src/subagent/provider.rs +++ b/apps/infrastructure/src/subagent/provider.rs @@ -63,15 +63,21 @@ impl SubagentProvider { /// Resolve subagent provider and model from settings. /// -/// Flow: reads `settings.provider` and `settings.model` → if model is empty, +/// Flow: reads `settings.provider` and `settings.model` → if provider is +/// empty, falls back to `app_config.default_provider` → if model is empty, /// falls back to the provider config's `default_model` → if that is also -/// empty, uses the domain default model constant. +/// empty, uses `app_config.default_model` → finally the domain default model +/// constant. #[instrument] pub fn resolve_subagent_provider( settings: &zesdex_domain::cms::Settings, app_config: &zesdex_domain::cms::AppConfig, ) -> (String, String) { - let provider = settings.provider.clone(); + let provider = if settings.provider.is_empty() { + app_config.default_provider.clone() + } else { + settings.provider.clone() + }; let model = settings.model.clone(); // Use the default model from the provider config if available @@ -80,6 +86,7 @@ pub fn resolve_subagent_provider( .providers .get(&provider) .and_then(|p| p.default_model.clone()) + .or_else(|| Some(app_config.default_model.clone())) .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_MODEL.to_string()) } else { model