fix(config): preserve named custom provider launches

Accept a dynamic root provider only when it names an exact openai-compatible provider table, then retain that identifier across dispatcher reads, runtime resolution, and typed config saves.

Keep built-in provider parsing and untrusted project overlays strict, and cover the TUI persistence-to-dispatcher launch boundary.

Refs #4682

Co-authored-by: e792a8 <25414586+e792a8@users.noreply.github.com>
Signed-off-by: Hunter B <hmbown@gmail.com>
This commit is contained in:
Hunter B
2026-07-22 05:13:18 -07:00
parent b60b3fecdb
commit fa46105a71
4 changed files with 298 additions and 20 deletions
+29 -5
View File
@@ -5284,7 +5284,9 @@ mod tests {
}
#[test]
fn raw_provider_dispatch_defers_dynamic_config_to_the_tui() {
fn persisted_custom_provider_crosses_config_and_root_tui_launch_boundary() {
let _lock = env_lock();
let (_tui_dir, _tui_bin) = install_fake_tui_binary();
let dir = tempfile::TempDir::new().expect("tempdir");
let config_path = dir.path().join("config.toml");
std::fs::write(
@@ -5298,12 +5300,34 @@ model = "qwen-2.5-7b"
"#,
)
.expect("custom provider config fixture");
assert!(
ConfigStore::load(Some(config_path.clone())).is_err(),
"the enum-backed dispatcher store must not be the owner of dynamic provider config"
);
let store = ConfigStore::load(Some(config_path.clone()))
.expect("a TUI-persisted custom provider must cross the dispatcher parser");
assert_eq!(store.config.provider, ProviderKind::Custom);
assert_eq!(store.config.provider_id(), "lm-studio");
let resolved = store
.config
.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::Custom);
assert_eq!(resolved.base_url, "http://127.0.0.1:1234/v1");
assert_eq!(resolved.model, "qwen-2.5-7b");
let config = config_path.to_string_lossy().into_owned();
let root_cli = parse_ok(&["codewhale", "--config", &config]);
let root_command = build_tui_command(&root_cli, &resolved, Vec::new())
.expect("root launch should reach the TUI command boundary");
let root_args = root_command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert!(
root_args
.windows(2)
.any(|args| args == ["--config", &config])
);
assert_eq!(command_env(&root_command, "CODEWHALE_PROVIDER"), None);
assert_eq!(command_env(&root_command, "DEEPSEEK_PROVIDER"), None);
let cli = parse_ok(&[
"codewhale",
"--config",
+154 -15
View File
@@ -506,6 +506,15 @@ impl ProvidersToml {
}
}
fn deserialize_root_provider<'de, D>(deserializer: D) -> std::result::Result<ProviderKind, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
let strict = serde::de::value::StringDeserializer::<D::Error>::new(value);
Ok(ProviderKind::deserialize(strict).unwrap_or(ProviderKind::Custom))
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConfigToml {
/// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek`
@@ -518,8 +527,17 @@ pub struct ConfigToml {
pub http_headers: BTreeMap<String, String>,
/// TUI-compatible default DeepSeek model.
pub default_text_model: Option<String>,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_root_provider")]
pub provider: ProviderKind,
/// Exact id for a dynamically named root provider.
///
/// This is runtime parse state rather than a second on-disk key. The
/// serialized `provider` value is restored by [`ConfigStore`] so a typed
/// dispatcher read/write cannot collapse `[providers.<name>]` back to the
/// legacy literal `custom` route.
#[doc(hidden)]
#[serde(skip)]
pub selected_provider_id: Option<String>,
pub model: Option<String>,
pub auth_mode: Option<String>,
pub output_mode: Option<String>,
@@ -1957,6 +1975,73 @@ pub struct LspConfigToml {
}
impl ConfigToml {
/// Exact configured provider id, including a dynamically named custom
/// provider selected by the TUI.
#[must_use]
pub fn provider_id(&self) -> &str {
self.named_custom_provider_id()
.unwrap_or_else(|| self.provider.as_str())
}
/// Return the exact id only when the root selection names a dynamic custom
/// provider rather than the legacy literal `custom` route.
#[must_use]
pub fn named_custom_provider_id(&self) -> Option<&str> {
(self.provider == ProviderKind::Custom)
.then_some(self.selected_provider_id.as_deref())
.flatten()
}
fn named_custom_provider_table(&self, provider_id: &str) -> Result<&toml::value::Table> {
let table = self
.providers
.extras
.get(provider_id)
.and_then(toml::Value::as_table)
.with_context(|| {
format!(
"custom provider '{provider_id}' requires a matching [providers.{provider_id}] table"
)
})?;
let compatible = table
.get("kind")
.and_then(toml::Value::as_str)
.is_some_and(|kind| {
kind.trim()
.to_ascii_lowercase()
.replace('_', "-")
.eq("openai-compatible")
});
if !compatible {
bail!(
"custom provider '{provider_id}' must set [providers.{provider_id}].kind = \"openai-compatible\""
);
}
Ok(table)
}
fn named_custom_provider_config(&self) -> Option<ProviderConfigToml> {
let provider_id = self.named_custom_provider_id()?;
self.named_custom_provider_table(provider_id).ok()?;
self.providers
.extras
.get(provider_id)
.cloned()?
.try_into()
.ok()
}
fn bind_persisted_provider_id(&mut self, provider_id: &str) -> Result<()> {
self.selected_provider_id = None;
if self.provider != ProviderKind::Custom || provider_id == ProviderKind::Custom.as_str() {
return Ok(());
}
self.named_custom_provider_table(provider_id)?;
self.selected_provider_id = Some(provider_id.to_string());
Ok(())
}
/// Merge safe project-level overrides from `$WORKSPACE/.codewhale/config.toml`
/// or legacy `$WORKSPACE/.deepseek/config.toml`.
///
@@ -2009,7 +2094,7 @@ impl ConfigToml {
}
match key {
"provider" => Some(self.provider.as_str().to_string()),
"provider" => Some(self.provider_id().to_string()),
"stream_chunk_timeout_secs" | "tui.stream_chunk_timeout_secs" => {
Some(self.stream_chunk_timeout_secs().to_string())
}
@@ -2091,12 +2176,21 @@ impl ConfigToml {
match key {
"provider" => {
self.provider = ProviderKind::parse(value).with_context(|| {
format!(
"unknown provider '{value}': expected {}",
ProviderKind::names_hint()
)
})?;
if let Some(provider) = ProviderKind::parse(value) {
self.provider = provider;
self.selected_provider_id = None;
} else {
let provider_id = value.trim();
self.named_custom_provider_table(provider_id)
.with_context(|| {
format!(
"unknown provider '{value}': expected {} or a configured custom provider",
ProviderKind::names_hint()
)
})?;
self.provider = ProviderKind::Custom;
self.selected_provider_id = Some(provider_id.to_string());
}
}
"api_key" => self.api_key = Some(value.to_string()),
"base_url" => self.base_url = Some(value.to_string()),
@@ -2132,7 +2226,10 @@ impl ConfigToml {
}
match key {
"provider" => self.provider = ProviderKind::Deepseek,
"provider" => {
self.provider = ProviderKind::Deepseek;
self.selected_provider_id = None;
}
"api_key" => self.api_key = None,
"base_url" => self.base_url = None,
"http_headers" => self.http_headers.clear(),
@@ -2160,7 +2257,7 @@ impl ConfigToml {
#[must_use]
pub fn list_values(&self) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
out.insert("provider".to_string(), self.provider.as_str().to_string());
out.insert("provider".to_string(), self.provider_id().to_string());
if let Some(v) = self.api_key.as_ref() {
out.insert("api_key".to_string(), redact_secret(v));
@@ -2258,7 +2355,14 @@ impl ConfigToml {
(self.provider, ProviderSource::Config)
};
let mut provider_cfg = self.providers.for_provider(provider).clone();
let mut provider_cfg = if provider == ProviderKind::Custom
&& matches!(provider_source, ProviderSource::Config)
{
self.named_custom_provider_config()
.unwrap_or_else(|| self.providers.for_provider(provider).clone())
} else {
self.providers.for_provider(provider).clone()
};
if provider == ProviderKind::SiliconflowCN {
let fb = &self.providers.siliconflow;
if provider_cfg.api_key.is_none() {
@@ -2595,8 +2699,23 @@ pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
return None;
}
};
match toml::from_str(&raw) {
Ok(config) => return Some(config),
match toml::from_str::<ConfigToml>(&raw) {
Ok(config) => {
let raw_provider = toml::from_str::<toml::Value>(&raw)
.ok()
.and_then(|document| document.get("provider").cloned())
.and_then(|provider| provider.as_str().map(str::to_string));
if config.provider == ProviderKind::Custom
&& raw_provider.as_deref() != Some(ProviderKind::Custom.as_str())
{
tracing::warn!(
"Failed to parse project config {}; file contents were omitted",
quote_os_path(&path)
);
return None;
}
return Some(config);
}
Err(_) => {
tracing::warn!(
"Failed to parse project config {}; file contents were omitted",
@@ -3457,12 +3576,25 @@ impl ConfigStore {
let path = resolve_config_path(path)?;
let (config, original_raw) = if checked_path_exists(&path)? {
let raw = read_checked_config_file(&path)?;
let parsed: ConfigToml = toml::from_str(&raw).map_err(|_| {
let mut parsed: ConfigToml = toml::from_str(&raw).map_err(|_| {
anyhow::anyhow!(
"failed to parse config at {}; file contents were omitted",
quote_os_path(&path)
)
})?;
let raw_document: toml::Value = toml::from_str(&raw).map_err(|_| {
anyhow::anyhow!(
"failed to parse config at {}; file contents were omitted",
quote_os_path(&path)
)
})?;
if let Some(provider_id) = raw_document.get("provider").and_then(toml::Value::as_str) {
parsed
.bind_persisted_provider_id(provider_id)
.with_context(|| {
format!("failed to parse config at {}", quote_os_path(&path))
})?;
}
(parsed, Some(raw))
} else {
(ConfigToml::default(), None)
@@ -3483,8 +3615,15 @@ impl ConfigStore {
/// [`persistence::SetupTransaction`] alongside sibling files and keep the
/// comment-preserving write atomic with the rest of the transaction.
pub fn rendered_body(&self) -> Result<String> {
let serialized =
let mut serialized =
toml::to_string_pretty(&self.config).context("failed to serialize config")?;
if let Some(provider_id) = self.config.named_custom_provider_id() {
let mut document = serialized
.parse::<toml_edit::DocumentMut>()
.context("failed to edit serialized config")?;
document["provider"] = toml_edit::value(provider_id);
serialized = document.to_string();
}
if let Some(ref original_raw) = self.original_raw {
merge_and_preserve_comments(&serialized, original_raw).with_context(|| {
format!(
+107
View File
@@ -2949,6 +2949,29 @@ fn load_project_config_rejects_symlinked_primary_config() {
);
}
#[test]
fn load_project_config_keeps_unknown_provider_names_strict() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
let config_dir = workspace.path().join(CODEWHALE_APP_DIR);
fs::create_dir_all(&config_dir).expect("mkdir project config");
fs::write(
config_dir.join(CONFIG_FILE_NAME),
r#"provider = "opencode_zen"
model = "must-not-apply"
[providers.opencode_zen]
kind = "openai-compatible"
base_url = "https://opencode.example/v1"
"#,
)
.expect("write project config");
assert!(
load_project_config(workspace.path()).is_none(),
"project overlays must not gain named-provider authority"
);
}
#[cfg(unix)]
#[test]
fn load_sibling_permissions_rejects_symlink_file() {
@@ -3664,6 +3687,90 @@ fn unknown_provider_error_lists_huggingface() {
assert!(message.contains("huggingface"));
}
#[test]
fn config_store_preserves_named_custom_provider_identity_across_typed_dispatch_reads() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(
&path,
r#"# written by the TUI custom-provider flow
provider = "opencode_zen"
[providers.opencode_zen]
kind = "openai-compatible"
base_url = "https://opencode.example/v1"
model = "deepseek-v4-flash-free"
api_key_env = "OPENCODE_ZEN_API_KEY"
"#,
)
.expect("custom provider fixture");
let mut store = ConfigStore::load(Some(path.clone())).expect("dispatcher config should load");
assert_eq!(store.config.provider, ProviderKind::Custom);
assert_eq!(store.config.provider_id(), "opencode_zen");
assert_eq!(
store.config.get_value("provider").as_deref(),
Some("opencode_zen")
);
assert_eq!(
store
.config
.list_values()
.get("provider")
.map(String::as_str),
Some("opencode_zen")
);
let resolved = store
.config
.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::Custom);
assert_eq!(resolved.provider_source, ProviderSource::Config);
assert_eq!(resolved.base_url, "https://opencode.example/v1");
assert_eq!(resolved.model, "deepseek-v4-flash-free");
store
.config
.set_value("telemetry", "false")
.expect("unrelated typed mutation");
let rendered = store
.rendered_body()
.expect("render custom provider config");
assert!(
rendered.contains("provider = \"opencode_zen\""),
"{rendered}"
);
assert!(!rendered.contains("provider = \"custom\""), "{rendered}");
assert!(!rendered.contains("[providers.custom]"), "{rendered}");
store.save().expect("save custom provider config");
let reloaded = ConfigStore::load(Some(path)).expect("reload custom provider config");
assert_eq!(reloaded.config.provider_id(), "opencode_zen");
}
#[test]
fn named_custom_root_provider_requires_a_matching_openai_compatible_table() {
for body in [
"provider = \"opencode_zen\"\n",
r#"provider = "opencode_zen"
[providers.opencode_zen]
kind = "anthropic-messages"
base_url = "https://opencode.example/v1"
"#,
] {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(&path, body).expect("invalid custom provider fixture");
let err = ConfigStore::load(Some(path)).expect_err("invalid custom route should fail");
let message = format!("{err:#}");
assert!(message.contains("opencode_zen"), "{message}");
assert!(message.contains("openai-compatible") || message.contains("matching"));
}
}
#[test]
fn provider_kind_accepts_legacy_deepseek_cn_aliases() {
for alias in [
+8
View File
@@ -817,6 +817,14 @@ mod tests {
);
assert_eq!(entry.model.as_deref(), Some("acme/code-1"));
assert_eq!(entry.api_key_env.as_deref(), Some("ACME_API_KEY"));
let dispatcher = codewhale_config::ConfigStore::load(Some(written))
.expect("the dispatcher must parse the exact config written by the TUI");
assert_eq!(
dispatcher.config.provider,
codewhale_config::ProviderKind::Custom
);
assert_eq!(dispatcher.config.provider_id(), "acme_ai");
}
#[test]