diff --git a/crates/arc-api/src/serve.rs b/crates/arc-api/src/serve.rs index c9b5bf1fd..330c092d3 100644 --- a/crates/arc-api/src/serve.rs +++ b/crates/arc-api/src/serve.rs @@ -1,14 +1,16 @@ -use std::sync::Arc; +use std::sync::{Arc, RwLock}; +use std::time::Duration; use arc_llm::provider::Provider; use arc_util::terminal::Styles; use tokio::net::TcpListener; -use tracing::info; +use tracing::{info, warn}; use clap::Args; use crate::jwt_auth::{AuthMode, AuthStrategy}; use crate::server::{build_router, create_app_state_with_options}; +use crate::server_config::ServerConfig; use crate::tls::ClientAuth; use arc_workflows::cli::backend::AgentApiBackend; use arc_workflows::cli::SandboxProvider; @@ -79,31 +81,25 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: } }; - // Resolve model/provider defaults - let provider_str = args.provider; - let model = args.model.unwrap_or_else(|| match provider_str.as_deref() { - Some("openai") => "gpt-5.2".to_string(), - Some("gemini") => "gemini-3.1-pro-preview".to_string(), - _ => "claude-opus-4-6".to_string(), - }); + // Initialize data directory and SQLite database + let server_config = crate::server_config::load_server_config()?; + let data_dir = crate::server_config::resolve_data_dir(&server_config); - // Resolve model alias through catalog - let (model, provider_str) = match arc_llm::catalog::get_model_info(&model) { - Some(info) => (info.id, provider_str.or(Some(info.provider))), - None => (model, provider_str), - }; + // Shared config for live reloading + let shared_config = Arc::new(RwLock::new(server_config)); - // Parse provider string to enum (defaults to Anthropic) - let provider_enum: Provider = provider_str - .as_deref() - .map(|s| s.parse::()) - .transpose() - .map_err(|e| anyhow::anyhow!("{e}"))? - .unwrap_or(Provider::Anthropic); + // CLI overrides take precedence over config file values, even after reload + let cli_model = args.model; + let cli_provider = args.provider; - // Build registry factory + // Build registry factory that reads live config + let config_for_factory = Arc::clone(&shared_config); let factory = move |interviewer: Arc| { - let model = model.clone(); + let (model, provider_enum) = resolve_model_provider( + &config_for_factory, + cli_model.as_deref(), + cli_provider.as_deref(), + ); default_registry(interviewer, move || { if dry_run_mode { None @@ -115,35 +111,40 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: } }) }; - - // Initialize data directory and SQLite database - let server_config = crate::server_config::load_server_config()?; - let data_dir = crate::server_config::resolve_data_dir(&server_config); std::fs::create_dir_all(&data_dir)?; let db = arc_db::connect(&data_dir.join("arc.db")).await?; arc_db::initialize_db(&db).await?; - let auth_mode = if args.demo { - crate::jwt_auth::AuthMode::Disabled - } else { - crate::jwt_auth::resolve_auth_mode( - &server_config.api, - server_config.web.auth.allowed_usernames.clone(), - ) + let (auth_mode, client_auth, max_concurrent_runs) = { + let cfg = shared_config.read().expect("config lock poisoned"); + let auth_mode = if args.demo { + crate::jwt_auth::AuthMode::Disabled + } else { + crate::jwt_auth::resolve_auth_mode( + &cfg.api, + cfg.web.auth.allowed_usernames.clone(), + ) + }; + let client_auth = cfg + .api + .tls + .as_ref() + .map(|_| client_auth_from_mode(&auth_mode)); + let max_concurrent_runs = args + .max_concurrent_runs + .or(cfg.max_concurrent_runs) + .unwrap_or(5); + (auth_mode, client_auth, max_concurrent_runs) }; - // Derive client auth mode before auth_mode is moved into the router - let client_auth = server_config - .api - .tls - .as_ref() - .map(|_| client_auth_from_mode(&auth_mode)); - - let max_concurrent_runs = args - .max_concurrent_runs - .or(server_config.max_concurrent_runs) - .unwrap_or(5); - let state = create_app_state_with_options(db, factory, dry_run_mode, args.demo, max_concurrent_runs); + let state = create_app_state_with_options( + db, + factory, + dry_run_mode, + args.demo, + max_concurrent_runs, + Arc::clone(&shared_config), + ); crate::server::spawn_scheduler(Arc::clone(&state)); let router = build_router(state, auth_mode); @@ -163,8 +164,38 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: eprintln!("{}", styles.dim.apply_to("(dry-run mode)")); } + // Spawn config polling task (skip in demo mode) + if !args.demo { + let config_for_poll = Arc::clone(&shared_config); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(5)); + interval.tick().await; // skip first immediate tick + loop { + interval.tick().await; + match crate::server_config::load_server_config() { + Ok(new_config) => { + let mut cfg = config_for_poll.write().expect("config lock poisoned"); + if *cfg != new_config { + info!("Server config reloaded"); + *cfg = new_config; + } + } + Err(e) => { + warn!("Failed to reload server config, keeping previous: {e}"); + } + } + } + }); + } + // Branch: TLS or plain HTTP (demo mode always uses plain HTTP) - if let (false, Some(ref tls_config)) = (args.demo, &server_config.api.tls) { + let tls_config = shared_config + .read() + .expect("config lock poisoned") + .api + .tls + .clone(); + if let (false, Some(ref tls_config)) = (args.demo, &tls_config) { let client_auth = client_auth.unwrap(); let rustls_config = crate::tls::build_rustls_config(tls_config, client_auth); @@ -180,6 +211,53 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: Ok(()) } +/// Resolve model and provider from shared config, with CLI overrides taking precedence. +fn resolve_model_provider( + shared_config: &RwLock, + cli_model: Option<&str>, + cli_provider: Option<&str>, +) -> (String, Provider) { + let cfg = shared_config.read().expect("config lock poisoned"); + let config_provider = cfg + .run_defaults + .llm + .as_ref() + .and_then(|l| l.provider.as_deref()); + let config_model = cfg + .run_defaults + .llm + .as_ref() + .and_then(|l| l.model.as_deref()); + + let provider_str = cli_provider.or(config_provider); + let model = cli_model + .map(|s| s.to_string()) + .or_else(|| config_model.map(|s| s.to_string())) + .unwrap_or_else(|| { + // Look up default model from catalog for the given provider + let default_info = provider_str + .and_then(arc_llm::catalog::default_model_for_provider) + .unwrap_or_else(arc_llm::catalog::default_model); + default_info.id + }); + + // Resolve model alias through catalog + let (model, provider_str) = match arc_llm::catalog::get_model_info(&model) { + Some(info) => ( + info.id, + provider_str.map(|s| s.to_string()).or(Some(info.provider)), + ), + None => (model, provider_str.map(|s| s.to_string())), + }; + + let provider_enum: Provider = provider_str + .as_deref() + .and_then(|s| s.parse::().ok()) + .unwrap_or(Provider::Anthropic); + + (model, provider_enum) +} + /// Derive client certificate verification mode from the resolved auth strategies. fn client_auth_from_mode(auth_mode: &AuthMode) -> ClientAuth { let strategies = match auth_mode { diff --git a/crates/arc-api/src/server.rs b/crates/arc-api/src/server.rs index 941dabfe1..2194737b5 100644 --- a/crates/arc-api/src/server.rs +++ b/crates/arc-api/src/server.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; + +use crate::server_config::ServerConfig; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; @@ -88,6 +90,7 @@ pub struct AppState { pub db: sqlx::SqlitePool, max_concurrent_runs: usize, scheduler_notify: tokio::sync::Notify, + pub server_config: Arc>, } /// Build the axum Router with all run endpoints. @@ -293,7 +296,14 @@ pub fn create_app_state( db: sqlx::SqlitePool, registry_factory: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { - create_app_state_with_options(db, registry_factory, false, false, 5) + create_app_state_with_options( + db, + registry_factory, + false, + false, + 5, + Arc::new(RwLock::new(ServerConfig::default())), + ) } /// Create an `AppState` with the given database pool, registry factory, dry-run flag, and demo flag. @@ -303,6 +313,7 @@ pub fn create_app_state_with_options( dry_run: bool, is_demo: bool, max_concurrent_runs: usize, + server_config: Arc>, ) -> Arc { Arc::new(AppState { runs: Mutex::new(HashMap::new()), @@ -313,6 +324,7 @@ pub fn create_app_state_with_options( db, max_concurrent_runs, scheduler_notify: tokio::sync::Notify::new(), + server_config, }) } @@ -1584,7 +1596,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrency_limit_respected() { - let state = create_app_state_with_options(test_db().await, test_registry, false, false, 1); + let state = create_app_state_with_options(test_db().await, test_registry, false, false, 1, Arc::new(RwLock::new(ServerConfig::default()))); let app = test_app_with_scheduler(state); // Submit two runs with max_concurrent_runs=1 diff --git a/crates/arc-api/src/server_config.rs b/crates/arc-api/src/server_config.rs index a93e103cc..785a0229c 100644 --- a/crates/arc-api/src/server_config.rs +++ b/crates/arc-api/src/server_config.rs @@ -92,7 +92,7 @@ impl Default for WebConfig { } } -#[derive(Debug, Default, Deserialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] pub struct ServerConfig { pub data_dir: Option, pub max_concurrent_runs: Option, diff --git a/crates/arc-llm/src/catalog.json b/crates/arc-llm/src/catalog.json index 9a6efd448..8bbe20698 100644 --- a/crates/arc-llm/src/catalog.json +++ b/crates/arc-llm/src/catalog.json @@ -11,7 +11,8 @@ "input_cost_per_million": 15.0, "output_cost_per_million": 75.0, "estimated_output_tps": 25, - "aliases": ["opus", "claude-opus"] + "aliases": ["opus", "claude-opus"], + "default": true }, { "id": "claude-sonnet-4-5", @@ -53,7 +54,8 @@ "input_cost_per_million": 1.75, "output_cost_per_million": 14.0, "estimated_output_tps": 65, - "aliases": ["gpt5"] + "aliases": ["gpt5"], + "default": true }, { "id": "gpt-5-mini", @@ -109,7 +111,8 @@ "input_cost_per_million": 2.0, "output_cost_per_million": 12.0, "estimated_output_tps": 85, - "aliases": ["gemini-pro"] + "aliases": ["gemini-pro"], + "default": true }, { "id": "gemini-3-flash-preview", @@ -151,7 +154,8 @@ "input_cost_per_million": 0.6, "output_cost_per_million": 3.0, "estimated_output_tps": 50, - "aliases": ["kimi"] + "aliases": ["kimi"], + "default": true }, { "id": "glm-4.7", @@ -165,7 +169,8 @@ "input_cost_per_million": 0.6, "output_cost_per_million": 2.2, "estimated_output_tps": 100, - "aliases": ["glm", "glm4"] + "aliases": ["glm", "glm4"], + "default": true }, { "id": "minimax-m2.5", @@ -179,7 +184,8 @@ "input_cost_per_million": 0.3, "output_cost_per_million": 1.2, "estimated_output_tps": 45, - "aliases": ["minimax"] + "aliases": ["minimax"], + "default": true }, { "id": "mercury-2", @@ -193,6 +199,7 @@ "input_cost_per_million": 0.25, "output_cost_per_million": 0.75, "estimated_output_tps": 1000, - "aliases": ["mercury"] + "aliases": ["mercury"], + "default": true } ] diff --git a/crates/arc-llm/src/catalog.rs b/crates/arc-llm/src/catalog.rs index 58ac9ce34..9a9cdbfa9 100644 --- a/crates/arc-llm/src/catalog.rs +++ b/crates/arc-llm/src/catalog.rs @@ -16,6 +16,27 @@ pub fn get_model_info(model_id: &str) -> Option { .cloned() } +/// Get the default model for a provider, as marked in catalog.json. +/// +/// Returns `None` if the provider has no models or none marked as default. +#[must_use] +pub fn default_model_for_provider(provider: &str) -> Option { + BUILT_IN_MODELS + .iter() + .find(|m| m.provider == provider && m.default) + .cloned() +} + +/// Get the overall default model (the first model marked `default` in catalog.json). +#[must_use] +pub fn default_model() -> ModelInfo { + BUILT_IN_MODELS + .iter() + .find(|m| m.default) + .cloned() + .expect("catalog.json must contain at least one default model") +} + /// List all known models, optionally filtered by provider (Section 2.9). #[must_use] pub fn list_models(provider: Option<&str>) -> Vec { @@ -49,6 +70,45 @@ mod tests { } } + #[test] + fn every_provider_has_exactly_one_default_model() { + for &provider in Provider::ALL { + let defaults: Vec<_> = list_models(Some(provider.as_str())) + .into_iter() + .filter(|m| m.default) + .collect(); + assert_eq!( + defaults.len(), + 1, + "Provider {:?} should have exactly one default model, found {}: {:?}", + provider, + defaults.len(), + defaults.iter().map(|m| &m.id).collect::>() + ); + } + } + + #[test] + fn default_model_returns_first_catalog_default() { + let m = default_model(); + assert!(m.default); + } + + #[test] + fn default_model_for_provider_returns_correct_model() { + let m = default_model_for_provider("anthropic").unwrap(); + assert_eq!(m.id, "claude-opus-4-6"); + assert!(m.default); + + let m = default_model_for_provider("openai").unwrap(); + assert_eq!(m.id, "gpt-5.2"); + + let m = default_model_for_provider("gemini").unwrap(); + assert_eq!(m.id, "gemini-3.1-pro-preview"); + + assert!(default_model_for_provider("nonexistent").is_none()); + } + #[test] fn catalog_provider_strings_roundtrip_through_provider() { for model in list_models(None) { diff --git a/crates/arc-llm/src/types.rs b/crates/arc-llm/src/types.rs index 6f011dc76..f2a81e037 100644 --- a/crates/arc-llm/src/types.rs +++ b/crates/arc-llm/src/types.rs @@ -641,6 +641,9 @@ pub struct ModelInfo { pub output_cost_per_million: Option, pub estimated_output_tps: Option, pub aliases: Vec, + /// Whether this is the default model for its provider. + #[serde(default)] + pub default: bool, } // --- 4.7 Timeouts --- diff --git a/crates/arc-workflows/src/cli/run_config.rs b/crates/arc-workflows/src/cli/run_config.rs index 60c6254ec..12b76055d 100644 --- a/crates/arc-workflows/src/cli/run_config.rs +++ b/crates/arc-workflows/src/cli/run_config.rs @@ -21,19 +21,19 @@ pub struct WorkflowRunConfig { pub vars: Option>, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, PartialEq)] pub struct LlmConfig { pub model: Option, pub provider: Option, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, PartialEq)] pub struct SetupConfig { pub commands: Vec, pub timeout_ms: Option, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, PartialEq)] pub struct SandboxConfig { pub provider: Option, pub preserve: Option, @@ -43,7 +43,7 @@ pub struct SandboxConfig { /// Defaults for workflow runs, loaded from the server config. /// /// Fields mirror `WorkflowRunConfig` but are all optional. -#[derive(Clone, Debug, Default, Deserialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] pub struct RunDefaults { pub directory: Option, pub llm: Option, diff --git a/crates/arc-workflows/src/daytona_sandbox.rs b/crates/arc-workflows/src/daytona_sandbox.rs index c7e929dff..2a2073b71 100644 --- a/crates/arc-workflows/src/daytona_sandbox.rs +++ b/crates/arc-workflows/src/daytona_sandbox.rs @@ -16,7 +16,7 @@ const DEFAULT_IMAGE: &str = "ubuntu:22.04"; /// Configuration for a Daytona cloud sandbox. /// /// Doubles as the TOML deserialization target for `[sandbox.daytona]`. -#[derive(Clone, Debug, Default, Deserialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] pub struct DaytonaConfig { pub auto_stop_interval: Option, pub labels: Option>, @@ -25,7 +25,7 @@ pub struct DaytonaConfig { /// Snapshot configuration: when present, the sandbox is created from a snapshot /// instead of a bare Docker image. -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, PartialEq)] pub struct DaytonaSnapshotConfig { pub name: String, pub cpu: Option,