Add hot config reload for server.toml with catalog-driven model defaults

Poll ~/.arc/server.toml every 5s and swap run_defaults/git config for new
runs without restarting. CLI overrides (--model, --provider) always win.
On parse error, log a warning and keep the previous config.

Also add a `default` field to the model catalog so default model resolution
uses catalog data instead of hardcoded model names in Rust code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-04 23:11:35 -05:00
parent 9274208c24
commit 3df2961d22
8 changed files with 224 additions and 64 deletions

View file

@ -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::<Provider>())
.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<dyn Interviewer>| {
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<ServerConfig>,
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::<Provider>().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 {

View file

@ -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<RwLock<ServerConfig>>,
}
/// 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<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
) -> Arc<AppState> {
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<RwLock<ServerConfig>>,
) -> Arc<AppState> {
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

View file

@ -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<PathBuf>,
pub max_concurrent_runs: Option<usize>,

View file

@ -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
}
]

View file

@ -16,6 +16,27 @@ pub fn get_model_info(model_id: &str) -> Option<ModelInfo> {
.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<ModelInfo> {
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<ModelInfo> {
@ -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::<Vec<_>>()
);
}
}
#[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) {

View file

@ -641,6 +641,9 @@ pub struct ModelInfo {
pub output_cost_per_million: Option<f64>,
pub estimated_output_tps: Option<f64>,
pub aliases: Vec<String>,
/// Whether this is the default model for its provider.
#[serde(default)]
pub default: bool,
}
// --- 4.7 Timeouts ---

View file

@ -21,19 +21,19 @@ pub struct WorkflowRunConfig {
pub vars: Option<HashMap<String, String>>,
}
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct LlmConfig {
pub model: Option<String>,
pub provider: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct SetupConfig {
pub commands: Vec<String>,
pub timeout_ms: Option<u64>,
}
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct SandboxConfig {
pub provider: Option<String>,
pub preserve: Option<bool>,
@ -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<String>,
pub llm: Option<LlmConfig>,

View file

@ -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<i32>,
pub labels: Option<HashMap<String, String>>,
@ -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<i32>,