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:
+29
-5
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user