From 5b1c801cf1ea9902eafd85e5c82fabb71a3f2edc Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 24 May 2026 08:49:06 -0700 Subject: [PATCH] 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) --- .../src/commands/project_commands.rs | 19 +++- app/src-tauri/src/docker/container.rs | 102 ++++++++++++++---- app/src-tauri/src/models/app_settings.rs | 25 +++++ app/src/components/settings/AwsSettings.tsx | 13 +++ .../components/settings/OllamaSettings.tsx | 53 +++++++++ .../settings/OpenAiCompatibleSettings.tsx | 53 +++++++++ app/src/components/settings/SettingsPanel.tsx | 6 ++ app/src/lib/types.ts | 13 +++ 8 files changed, 258 insertions(+), 26 deletions(-) create mode 100644 app/src/components/settings/OllamaSettings.tsx create mode 100644 app/src/components/settings/OpenAiCompatibleSettings.tsx diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 8ce0306..bbdc63d 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -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(), diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 7366109..83ba096 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -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"); diff --git a/app/src-tauri/src/models/app_settings.rs b/app/src-tauri/src/models/app_settings.rs index 67a8373..f25bde8 100644 --- a/app/src-tauri/src/models/app_settings.rs +++ b/app/src-tauri/src/models/app_settings.rs @@ -32,6 +32,8 @@ pub struct GlobalAwsSettings { pub aws_profile: Option, #[serde(default)] pub aws_region: Option, + #[serde(default)] + pub default_model_id: Option, } 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, + #[serde(default)] + pub default_model_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct GlobalOpenAiCompatibleSettings { + #[serde(default)] + pub base_url: Option, + #[serde(default)] + pub default_model_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppSettings { #[serde(default)] @@ -60,6 +79,10 @@ pub struct AppSettings { pub custom_image_name: Option, #[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, #[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, diff --git a/app/src/components/settings/AwsSettings.tsx b/app/src/components/settings/AwsSettings.tsx index 19b2752..20a2ad0 100644 --- a/app/src/components/settings/AwsSettings.tsx +++ b/app/src/components/settings/AwsSettings.tsx @@ -12,6 +12,7 @@ export default function AwsSettings() { aws_config_path: null, aws_profile: null, aws_region: null, + default_model_id: null, }; // Load profiles when component mounts or aws_config_path changes @@ -105,6 +106,18 @@ export default function AwsSettings() { className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]" /> + + {/* Default Model ID */} +
+ Default Model ID + handleChange("default_model_id", e.target.value)} + placeholder="anthropic.claude-sonnet-4-20250514-v1:0" + className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]" + /> +
); diff --git a/app/src/components/settings/OllamaSettings.tsx b/app/src/components/settings/OllamaSettings.tsx new file mode 100644 index 0000000..b52c35a --- /dev/null +++ b/app/src/components/settings/OllamaSettings.tsx @@ -0,0 +1,53 @@ +import { useSettings } from "../../hooks/useSettings"; +import Tooltip from "../ui/Tooltip"; + +export default function OllamaSettings() { + const { appSettings, saveSettings } = useSettings(); + + const globalOllama = appSettings?.global_ollama ?? { + base_url: null, + default_model_id: null, + }; + + const handleChange = async (field: "base_url" | "default_model_id", value: string) => { + if (!appSettings) return; + await saveSettings({ + ...appSettings, + global_ollama: { ...globalOllama, [field]: value || null }, + }); + }; + + return ( +
+ +
+

+ Global Ollama defaults. Used when a per-project field is blank. + Changes here require a container rebuild to take effect. +

+ +
+ Default Base URL + handleChange("base_url", e.target.value)} + placeholder="http://host.docker.internal:11434" + className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]" + /> +
+ +
+ Default Model + handleChange("default_model_id", e.target.value)} + placeholder="qwen3.5:27b" + className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]" + /> +
+
+
+ ); +} diff --git a/app/src/components/settings/OpenAiCompatibleSettings.tsx b/app/src/components/settings/OpenAiCompatibleSettings.tsx new file mode 100644 index 0000000..0dc2e94 --- /dev/null +++ b/app/src/components/settings/OpenAiCompatibleSettings.tsx @@ -0,0 +1,53 @@ +import { useSettings } from "../../hooks/useSettings"; +import Tooltip from "../ui/Tooltip"; + +export default function OpenAiCompatibleSettings() { + const { appSettings, saveSettings } = useSettings(); + + const globalOai = appSettings?.global_openai_compatible ?? { + base_url: null, + default_model_id: null, + }; + + const handleChange = async (field: "base_url" | "default_model_id", value: string) => { + if (!appSettings) return; + await saveSettings({ + ...appSettings, + global_openai_compatible: { ...globalOai, [field]: value || null }, + }); + }; + + return ( +
+ +
+

+ Global defaults for any OpenAI-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.). + Used when a per-project field is blank. Changes require a container rebuild. +

+ +
+ Default Base URL + handleChange("base_url", e.target.value)} + placeholder="http://host.docker.internal:4000" + className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]" + /> +
+ +
+ Default Model + handleChange("default_model_id", e.target.value)} + placeholder="gpt-4o / gemini-pro / etc." + className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]" + /> +
+
+
+ ); +} diff --git a/app/src/components/settings/SettingsPanel.tsx b/app/src/components/settings/SettingsPanel.tsx index 5bfe422..f6e6d4a 100644 --- a/app/src/components/settings/SettingsPanel.tsx +++ b/app/src/components/settings/SettingsPanel.tsx @@ -1,6 +1,8 @@ import { useState, useEffect } from "react"; import DockerSettings from "./DockerSettings"; import AwsSettings from "./AwsSettings"; +import OllamaSettings from "./OllamaSettings"; +import OpenAiCompatibleSettings from "./OpenAiCompatibleSettings"; import { useSettings } from "../../hooks/useSettings"; import { useUpdates } from "../../hooks/useUpdates"; import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal"; @@ -148,6 +150,10 @@ export default function SettingsPanel() { +
+ +
+ diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 7fe1f39..fd546b6 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -116,6 +116,17 @@ export interface GlobalAwsSettings { aws_config_path: string | null; aws_profile: string | null; aws_region: string | null; + default_model_id: string | null; +} + +export interface GlobalOllamaSettings { + base_url: string | null; + default_model_id: string | null; +} + +export interface GlobalOpenAiCompatibleSettings { + base_url: string | null; + default_model_id: string | null; } export interface AppSettings { @@ -126,6 +137,8 @@ export interface AppSettings { image_source: ImageSource; custom_image_name: string | null; global_aws: GlobalAwsSettings; + global_ollama: GlobalOllamaSettings; + global_openai_compatible: GlobalOpenAiCompatibleSettings; global_claude_instructions: string | null; global_custom_env_vars: EnvVar[]; auto_check_updates: boolean;