Add global backend defaults with runtime fallback

New fields: GlobalAwsSettings.default_model_id, plus
GlobalOllamaSettings and GlobalOpenAiCompatibleSettings (base_url +
default_model_id each). When a per-project base_url or model_id is
blank, the container env vars and config fingerprints fall back to
the global value. Container recreation is triggered whenever the
resolved value changes, so editing a global default updates existing
projects on next start.

UI: added the new fields to AwsSettings and two new global settings
components, slotted into the Backends accordion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 08:49:06 -07:00
parent 9b78b4bc62
commit 5b1c801cf1
8 changed files with 258 additions and 26 deletions

View File

@@ -193,16 +193,20 @@ pub async fn start_project_container(
if project.backend == Backend::Ollama {
let ollama = project.ollama_config.as_ref()
.ok_or_else(|| "Ollama backend selected but no Ollama configuration found.".to_string())?;
if ollama.base_url.is_empty() {
return Err("Ollama base URL is required.".to_string());
if ollama.base_url.is_empty()
&& settings.global_ollama.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
{
return Err("Ollama base URL is required. Set it per-project or in global Ollama settings.".to_string());
}
}
if project.backend == Backend::OpenAiCompatible {
let oai_config = project.openai_compatible_config.as_ref()
.ok_or_else(|| "OpenAI Compatible backend selected but no configuration found.".to_string())?;
if oai_config.base_url.is_empty() {
return Err("OpenAI Compatible base URL is required.".to_string());
if oai_config.base_url.is_empty()
&& settings.global_openai_compatible.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
{
return Err("OpenAI Compatible base URL is required. Set it per-project or in global settings.".to_string());
}
}
@@ -334,6 +338,9 @@ pub async fn start_project_container(
let needs_recreate = docker::container_needs_recreation(
&existing_id,
&project,
&settings.global_aws,
&settings.global_ollama,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
settings.timezone.as_deref(),
@@ -369,6 +376,8 @@ pub async fn start_project_container(
&create_image,
aws_config_path.as_deref(),
&settings.global_aws,
&settings.global_ollama,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
settings.timezone.as_deref(),
@@ -406,6 +415,8 @@ pub async fn start_project_container(
&create_image,
aws_config_path.as_deref(),
&settings.global_aws,
&settings.global_ollama,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
settings.timezone.as_deref(),

View File

@@ -8,7 +8,7 @@ use std::collections::HashMap;
use sha2::{Sha256, Digest};
use super::client::get_docker;
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
@@ -256,9 +256,25 @@ fn sha256_hex(input: &str) -> String {
format!("{:x}", hasher.finalize())
}
/// Resolve a per-project string value with a global fallback. Returns `None`
/// when both are blank, otherwise the per-project value if set, else the global.
fn resolve_with_global<'a>(per_project: Option<&'a str>, global: Option<&'a str>) -> Option<&'a str> {
let project_val = per_project.map(str::trim).filter(|s| !s.is_empty());
if project_val.is_some() {
return project_val;
}
global.map(str::trim).filter(|s| !s.is_empty())
}
/// Compute a fingerprint for the Bedrock configuration so we can detect changes.
fn compute_bedrock_fingerprint(project: &Project) -> String {
/// Includes the resolved model_id (per-project blank → global default) so that
/// changing the global default forces a container recreation.
fn compute_bedrock_fingerprint(project: &Project, global_aws: &GlobalAwsSettings) -> String {
if let Some(ref bedrock) = project.bedrock_config {
let effective_model = resolve_with_global(
bedrock.model_id.as_deref(),
global_aws.default_model_id.as_deref(),
).unwrap_or("").to_string();
let parts = vec![
format!("{:?}", bedrock.auth_method),
bedrock.aws_region.clone(),
@@ -267,7 +283,7 @@ fn compute_bedrock_fingerprint(project: &Project) -> String {
bedrock.aws_session_token.as_deref().unwrap_or("").to_string(),
bedrock.aws_profile.as_deref().unwrap_or("").to_string(),
bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(),
bedrock.model_id.as_deref().unwrap_or("").to_string(),
effective_model,
format!("{}", bedrock.disable_prompt_caching),
bedrock.service_tier.as_deref().unwrap_or("").to_string(),
];
@@ -278,12 +294,18 @@ fn compute_bedrock_fingerprint(project: &Project) -> String {
}
/// Compute a fingerprint for the Ollama configuration so we can detect changes.
fn compute_ollama_fingerprint(project: &Project) -> String {
/// Includes the resolved base_url and model_id (per-project blank → global default).
fn compute_ollama_fingerprint(project: &Project, global_ollama: &GlobalOllamaSettings) -> String {
if let Some(ref ollama) = project.ollama_config {
let parts = vec![
ollama.base_url.clone(),
ollama.model_id.as_deref().unwrap_or("").to_string(),
];
let effective_url = resolve_with_global(
Some(&ollama.base_url),
global_ollama.base_url.as_deref(),
).unwrap_or("").to_string();
let effective_model = resolve_with_global(
ollama.model_id.as_deref(),
global_ollama.default_model_id.as_deref(),
).unwrap_or("").to_string();
let parts = vec![effective_url, effective_model];
sha256_hex(&parts.join("|"))
} else {
String::new()
@@ -291,12 +313,24 @@ fn compute_ollama_fingerprint(project: &Project) -> String {
}
/// Compute a fingerprint for the OpenAI Compatible configuration so we can detect changes.
fn compute_openai_compatible_fingerprint(project: &Project) -> String {
/// Includes the resolved base_url and model_id (per-project blank → global default).
fn compute_openai_compatible_fingerprint(
project: &Project,
global_openai_compatible: &GlobalOpenAiCompatibleSettings,
) -> String {
if let Some(ref config) = project.openai_compatible_config {
let effective_url = resolve_with_global(
Some(&config.base_url),
global_openai_compatible.base_url.as_deref(),
).unwrap_or("").to_string();
let effective_model = resolve_with_global(
config.model_id.as_deref(),
global_openai_compatible.default_model_id.as_deref(),
).unwrap_or("").to_string();
let parts = vec![
config.base_url.clone(),
effective_url,
config.api_key.as_deref().unwrap_or("").to_string(),
config.model_id.as_deref().unwrap_or("").to_string(),
effective_model,
];
sha256_hex(&parts.join("|"))
} else {
@@ -552,6 +586,8 @@ pub async fn create_container(
image_name: &str,
aws_config_path: Option<&str>,
global_aws: &GlobalAwsSettings,
global_ollama: &GlobalOllamaSettings,
global_openai_compatible: &GlobalOpenAiCompatibleSettings,
global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
timezone: Option<&str>,
@@ -659,7 +695,10 @@ pub async fn create_container(
}
}
if let Some(ref model) = bedrock.model_id {
if let Some(model) = resolve_with_global(
bedrock.model_id.as_deref(),
global_aws.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
}
@@ -679,9 +718,17 @@ pub async fn create_container(
// Ollama configuration
if project.backend == Backend::Ollama {
if let Some(ref ollama) = project.ollama_config {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", ollama.base_url));
if let Some(url) = resolve_with_global(
Some(&ollama.base_url),
global_ollama.base_url.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
}
env_vars.push("ANTHROPIC_AUTH_TOKEN=ollama".to_string());
if let Some(ref model) = ollama.model_id {
if let Some(model) = resolve_with_global(
ollama.model_id.as_deref(),
global_ollama.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
}
}
@@ -690,11 +737,19 @@ pub async fn create_container(
// OpenAI Compatible configuration
if project.backend == Backend::OpenAiCompatible {
if let Some(ref config) = project.openai_compatible_config {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", config.base_url));
if let Some(url) = resolve_with_global(
Some(&config.base_url),
global_openai_compatible.base_url.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
}
if let Some(ref key) = config.api_key {
env_vars.push(format!("ANTHROPIC_AUTH_TOKEN={}", key));
}
if let Some(ref model) = config.model_id {
if let Some(model) = resolve_with_global(
config.model_id.as_deref(),
global_openai_compatible.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
}
}
@@ -904,9 +959,9 @@ pub async fn create_container(
labels.insert("triple-c.project-name".to_string(), project.name.clone());
labels.insert("triple-c.backend".to_string(), format!("{:?}", project.backend));
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_fingerprint(project));
labels.insert("triple-c.openai-compatible-fingerprint".to_string(), compute_openai_compatible_fingerprint(project));
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project, global_aws));
labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_fingerprint(project, global_ollama));
labels.insert("triple-c.openai-compatible-fingerprint".to_string(), compute_openai_compatible_fingerprint(project, global_openai_compatible));
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
labels.insert("triple-c.image".to_string(), image_name.to_string());
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
@@ -1083,6 +1138,9 @@ pub async fn remove_project_volumes(project: &Project) -> Result<(), String> {
pub async fn container_needs_recreation(
container_id: &str,
project: &Project,
global_aws: &GlobalAwsSettings,
global_ollama: &GlobalOllamaSettings,
global_openai_compatible: &GlobalOpenAiCompatibleSettings,
global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
timezone: Option<&str>,
@@ -1154,7 +1212,7 @@ pub async fn container_needs_recreation(
}
// ── Bedrock config fingerprint ───────────────────────────────────────
let expected_bedrock_fp = compute_bedrock_fingerprint(project);
let expected_bedrock_fp = compute_bedrock_fingerprint(project, global_aws);
let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default();
if container_bedrock_fp != expected_bedrock_fp {
log::info!("Bedrock config mismatch");
@@ -1162,7 +1220,7 @@ pub async fn container_needs_recreation(
}
// ── Ollama config fingerprint ────────────────────────────────────────
let expected_ollama_fp = compute_ollama_fingerprint(project);
let expected_ollama_fp = compute_ollama_fingerprint(project, global_ollama);
let container_ollama_fp = get_label("triple-c.ollama-fingerprint").unwrap_or_default();
if container_ollama_fp != expected_ollama_fp {
log::info!("Ollama config mismatch");
@@ -1170,7 +1228,7 @@ pub async fn container_needs_recreation(
}
// ── OpenAI Compatible config fingerprint ────────────────────────────
let expected_oai_fp = compute_openai_compatible_fingerprint(project);
let expected_oai_fp = compute_openai_compatible_fingerprint(project, global_openai_compatible);
let container_oai_fp = get_label("triple-c.openai-compatible-fingerprint").unwrap_or_default();
if container_oai_fp != expected_oai_fp {
log::info!("OpenAI Compatible config mismatch");

View File

@@ -32,6 +32,8 @@ pub struct GlobalAwsSettings {
pub aws_profile: Option<String>,
#[serde(default)]
pub aws_region: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
}
impl Default for GlobalAwsSettings {
@@ -40,10 +42,27 @@ impl Default for GlobalAwsSettings {
aws_config_path: None,
aws_profile: None,
aws_region: None,
default_model_id: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlobalOllamaSettings {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlobalOpenAiCompatibleSettings {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppSettings {
#[serde(default)]
@@ -60,6 +79,10 @@ pub struct AppSettings {
pub custom_image_name: Option<String>,
#[serde(default)]
pub global_aws: GlobalAwsSettings,
#[serde(default)]
pub global_ollama: GlobalOllamaSettings,
#[serde(default)]
pub global_openai_compatible: GlobalOpenAiCompatibleSettings,
#[serde(default = "default_global_instructions")]
pub global_claude_instructions: Option<String>,
#[serde(default)]
@@ -156,6 +179,8 @@ impl Default for AppSettings {
image_source: ImageSource::default(),
custom_image_name: None,
global_aws: GlobalAwsSettings::default(),
global_ollama: GlobalOllamaSettings::default(),
global_openai_compatible: GlobalOpenAiCompatibleSettings::default(),
global_claude_instructions: default_global_instructions(),
global_custom_env_vars: Vec::new(),
auto_check_updates: true,