mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
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:
parent
9274208c24
commit
3df2961d22
8 changed files with 224 additions and 64 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue