fix(subagent): un-hardcode DeepSeek from model validation — accept any provider id (#3018)

Two changes to let non-DeepSeek providers use their own model IDs:

1. config.rs: add requested_model_for_provider() — DeepSeek providers
   use strict normalize_model_name(); all others accept any non-empty
   string via normalize_custom_model_id().

2. subagent/mod.rs: normalize_requested_subagent_model() now takes an
   ApiProvider parameter and delegates to requested_model_for_provider.
   Error messages name the active provider and list accepted model IDs
   from model_completion_names_for_provider() instead of hardcoding
   "Expected a DeepSeek model id".

parse_optional_subagent_model() keeps basic trimming-only validation;
provider-aware checks are deferred to the spawn path where the runtime
is available.
This commit is contained in:
Hunter Bown
2026-06-10 16:25:03 -07:00
parent b23067bacd
commit faeeeef59b
2 changed files with 53 additions and 5 deletions
+13
View File
@@ -575,6 +575,19 @@ pub(crate) fn normalize_custom_model_id(model: &str) -> Option<String> {
}
}
/// Validate a user-requested model id against the active provider (#3018).
///
/// DeepSeek providers use the strict `normalize_model_name` gate (official
/// API only accepts DeepSeek IDs). All other providers pass any non-empty,
/// non-control-character string through — the provider API is the authority.
#[must_use]
pub fn requested_model_for_provider(provider: ApiProvider, model: &str) -> Option<String> {
match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => normalize_model_name(model),
_ => normalize_custom_model_id(model),
}
}
fn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {
match model.trim().to_ascii_lowercase().as_str() {
"deepseek-v4-pro"
+40 -5
View File
@@ -5048,18 +5048,41 @@ fn with_default_fork_context(mut input: Value, default: bool) -> Value {
pub(crate) fn normalize_requested_subagent_model(
value: &str,
field: &str,
provider: crate::config::ApiProvider,
) -> Result<String, ToolError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(ToolError::invalid_input(format!("{field} cannot be blank")));
}
crate::config::normalize_model_name(trimmed).ok_or_else(|| {
// #3018: Use provider-aware validation so non-DeepSeek providers can
// accept their own model IDs instead of failing with "Expected a
// DeepSeek model id".
crate::config::requested_model_for_provider(provider, trimmed).ok_or_else(|| {
let valid_names = crate::config::model_completion_names_for_provider(provider);
let valid_hint = if valid_names.is_empty() {
String::new()
} else {
format!(" (accepted: {})", valid_names.join(", "))
};
ToolError::invalid_input(format!(
"Invalid {field} '{trimmed}'. Expected a DeepSeek model id such as deepseek-v4-pro or deepseek-v4-flash"
"Invalid {field} '{trimmed}' for provider {}{valid_hint}",
provider_name_for_error(provider)
))
})
}
fn provider_name_for_error(provider: crate::config::ApiProvider) -> &'static str {
match provider {
crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => {
"DeepSeek"
}
crate::config::ApiProvider::Openai | crate::config::ApiProvider::OpenaiCodex => "OpenAI",
crate::config::ApiProvider::Moonshot => "Moonshot",
crate::config::ApiProvider::Ollama => "Ollama",
_ => "this provider",
}
}
pub(crate) fn configured_model_for_role_or_type(
runtime: &SubAgentRuntime,
role: Option<&str>,
@@ -5074,8 +5097,12 @@ pub(crate) fn configured_model_for_role_or_type(
for key in keys {
if let Some(model) = runtime.role_models.get(&key) {
return normalize_requested_subagent_model(model, &format!("subagents.{key}.model"))
.map(Some);
return normalize_requested_subagent_model(
model,
&format!("subagents.{key}.model"),
runtime.client.api_provider(),
)
.map(Some);
}
}
Ok(None)
@@ -5264,7 +5291,15 @@ fn message_response_text(blocks: &[ContentBlock]) -> String {
fn parse_optional_subagent_model(input: &Value, key: &str) -> Result<Option<String>, ToolError> {
match input.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(value)) => normalize_requested_subagent_model(value, key).map(Some),
Some(Value::String(value)) => {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(ToolError::invalid_input(format!("{key} cannot be blank")));
}
// #3018: Basic parsing only — provider-aware validation is deferred
// to the spawn path where the runtime's ApiProvider is available.
Ok(Some(trimmed.to_string()))
}
Some(_) => Err(ToolError::invalid_input(format!("{key} must be a string"))),
}
}