Keep threads on their saved model provider when defaults change (#59503)

## Why

Changing the default model provider should not prevent users from resuming or continuing a thread that uses another configured provider.

## What changed

- Resolve cold resumes using the thread's saved provider and reject explicit attempts to switch it on resume.
- Use retained provider configuration for loaded threads without reloading the current default selection, while continuing to enforce managed provider policy.
- Preserve errors for missing provider definitions and policy conflicts.

## Testing

Update app-server and TUI coverage for resuming with a different default, recovering after restoring a missing provider definition, preserving drafts across resume retries, and continuing loaded threads with malformed system defaults.

GitOrigin-RevId: c5bf3b03cacde7349480c94c2f10bac827a0eb1f
This commit is contained in:
Abhinav
2026-11-04 22:20:18 +00:00
committed by copyberry
parent 6744e5eb79
commit e115d5f90d
13 changed files with 193 additions and 410 deletions
+12 -41
View File
@@ -434,27 +434,6 @@ impl ConfigManager {
Ok(CloudStartupConfig { bootstrap, config })
}
/// Admits resumed threads and input only on the currently selected provider.
pub(crate) async fn check_thread_model_provider_for_input(
&self,
current: &Config,
) -> std::io::Result<()> {
self.check_thread_model_provider(current).await?;
let configured = self
.load_latest_config(Some(current.cwd.to_path_buf()))
.await?;
if current.model_provider_id != configured.model_provider_id {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
ModelProviderError::ModelProviderMismatch {
model_provider: current.model_provider_id.clone(),
current_model_provider: configured.model_provider_id,
},
));
}
Ok(())
}
/// Installs local policy before cloud access; failed policy loads block traffic while
/// non-strict startup remains available for configuration repair.
pub(crate) async fn load_startup_config(
@@ -544,7 +523,7 @@ impl ConfigManager {
&self,
saved_provider: Option<&str>,
request_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
mut typesafe_overrides: ConfigOverrides,
cwd: Option<PathBuf>,
) -> std::io::Result<Config> {
let requested_provider = typesafe_overrides.model_provider.clone().or_else(|| {
@@ -554,11 +533,9 @@ impl ConfigManager {
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
});
// Resolve the selection before the saved provider is applied as a resume override.
let current_provider = self
.load_latest_config(typesafe_overrides.cwd.clone().or_else(|| cwd.clone()))
.await
.map(|config| config.model_provider_id);
if let Some(saved) = saved_provider {
typesafe_overrides.model_provider = Some(saved.to_owned());
}
self.load_with_validation(
&self.current_cli_overrides(),
request_overrides,
@@ -567,6 +544,7 @@ impl ConfigManager {
|config, _overrides, layers| {
let requested_provider = requested_provider
.as_deref()
.or(saved_provider)
.or(config.model_provider.as_deref())
.unwrap_or("openai");
// Validate the saved route even when a client sends a provider override.
@@ -597,20 +575,13 @@ impl ConfigManager {
));
}
}
let current_provider = current_provider?;
for provider in [
saved_provider.unwrap_or(requested_provider),
requested_provider,
] {
if provider != current_provider {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
ModelProviderError::ModelProviderMismatch {
model_provider: provider.to_owned(),
current_model_provider: current_provider,
},
));
}
if let Some(saved) = saved_provider
&& saved != requested_provider
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Cannot change this thread's model provider from `{saved}` to `{requested_provider}` on resume."),
));
}
Ok(())
},
@@ -261,62 +261,3 @@ async fn catalog_reload_uses_latest_model_provider() -> Result<()> {
assert_eq!(refreshed.model_provider_id, "openai");
Ok(())
}
/// Invocation overrides and selected profiles still define the current provider.
#[test_case("model_provider", "gateway"; "explicit_provider")]
#[test_case("profile", "gateway-profile"; "selected_profile")]
#[tokio::test]
async fn input_provider_check_uses_effective_selection(key: &str, value: &str) -> Result<()> {
let home = tempdir()?;
std::fs::write(
home.path().join("config.toml"),
r#"
model_provider = "openai"
[model_providers.gateway]
name = "Gateway"
"#,
)?;
let mut loader_overrides = LoaderOverrides::without_managed_config_for_tests();
let cli_overrides = if key == "profile" {
std::fs::write(
home.path().join(format!("{value}.config.toml")),
"model_provider = 'gateway'\n[model_providers.gateway]\nname = 'Gateway'\n",
)?;
loader_overrides.user_config_path = Some(AbsolutePathBuf::from_absolute_path(
home.path().join(format!("{value}.config.toml")),
)?);
loader_overrides.user_config_profile = Some(value.parse()?);
Vec::new()
} else {
vec![(key.into(), TomlValue::String(value.into()))]
};
let manager = ConfigManager::new_for_tests(
home.path().to_path_buf(),
cli_overrides,
loader_overrides,
CloudConfigBundleLoader::default(),
);
let current = manager
.load_latest_config(Some(home.path().to_path_buf()))
.await?;
assert_eq!(current.model_provider_id, "gateway");
manager
.check_thread_model_provider_for_input(&current)
.await?;
let default_manager =
ConfigManager::without_managed_config_for_tests(home.path().to_path_buf());
let error = default_manager
.check_thread_model_provider_for_input(&current)
.await
.unwrap_err();
assert_eq!(
error
.get_ref()
.and_then(|error| error.downcast_ref::<ModelProviderError>()),
Some(&ModelProviderError::ModelProviderMismatch {
model_provider: "gateway".into(),
current_model_provider: "openai".into(),
})
);
Ok(())
}
@@ -162,7 +162,7 @@ impl ThreadGoalRequestProcessor {
&& let Ok(thread) = self.thread_manager.get_thread(thread_id).await
{
self.config_manager
.check_thread_model_provider_for_input(thread.config().await.as_ref())
.check_thread_model_provider(thread.config().await.as_ref())
.await
.map_err(|error| config_load_error(&error))?;
}
@@ -2608,7 +2608,7 @@ impl ThreadRequestProcessor {
let (_, thread) = self.load_thread(&thread_id).await?;
ensure_direct_input_allowed(thread.as_ref()).await?;
self.config_manager
.check_thread_model_provider_for_input(thread.config().await.as_ref())
.check_thread_model_provider(thread.config().await.as_ref())
.await
.map_err(|error| config_load_error(&error))?;
self.submit_core_op(request_id, thread.as_ref(), Op::Compact)
@@ -4790,7 +4790,7 @@ impl ThreadRequestProcessor {
);
}
self.config_manager
.check_thread_model_provider_for_input(existing_thread.config().await.as_ref())
.check_thread_model_provider(existing_thread.config().await.as_ref())
.await
.map_err(|err| config_load_error(&err))?;
let redact_resume_payloads =
@@ -193,7 +193,7 @@ impl ThreadQueueRequestProcessor {
let thread = loaded_thread
.ok_or_else(|| invalid_request("resume the thread before starting a queued message"))?;
self.config_manager
.check_thread_model_provider_for_input(thread.config().await.as_ref())
.check_thread_model_provider(thread.config().await.as_ref())
.await
.map_err(|error| config_load_error(&error))?;
let submission = self
@@ -194,7 +194,7 @@ impl TurnRequestProcessor {
self.ensure_direct_input_allowed(request_id, thread.as_ref())
.await?;
self.config_manager
.check_thread_model_provider_for_input(thread.config().await.as_ref())
.check_thread_model_provider(thread.config().await.as_ref())
.await
.map_err(|error| config_load_error(&error))?;
match self
@@ -389,7 +389,7 @@ impl TurnRequestProcessor {
self.ensure_direct_input_allowed(request_id, thread.as_ref())
.await?;
self.config_manager
.check_thread_model_provider_for_input(thread.config().await.as_ref())
.check_thread_model_provider(thread.config().await.as_ref())
.await
.map_err(|error| config_load_error(&error))?;
let (review_request, display_text) = Self::review_request_from_target(params.target)?;
@@ -559,7 +559,7 @@ impl TurnRequestProcessor {
self.ensure_direct_input_allowed(&request_id, thread.as_ref())
.await?;
self.config_manager
.check_thread_model_provider_for_input(thread.config().await.as_ref())
.check_thread_model_provider(thread.config().await.as_ref())
.await
.map_err(|error| config_load_error(&error))?;
if let Some(tool_output) = &params.tool_output {
@@ -1095,7 +1095,7 @@ impl TurnRequestProcessor {
self.ensure_direct_input_allowed(request_id, thread.as_ref())
.await?;
self.config_manager
.check_thread_model_provider_for_input(thread.config().await.as_ref())
.check_thread_model_provider(thread.config().await.as_ref())
.await
.map_err(|error| config_load_error(&error))?;