fix(models): a provider default resolves without a registry row (#6043, SHA-6443)

`deepseek-flash` is the Deepseek descriptor default and was absent from the
ModelRegistry, so `codewhale model resolve deepseek-flash` failed with
"not available from provider deepseek" while the model was the documented
default. The same gap existed for every provider whose declared default
never got a hand-maintained row.

The descriptor owns its declared default: resolve now falls back to it —
for the provider it declared, never borrowing another provider's model.
Ollama keeps the old fail-closed path because its descriptor default is the
placeholder "unknown" and the real default comes from the live catalog.

New test `every_provider_default_resolves_for_its_own_provider` iterates
ProviderKind::all(); pre-fix it failed with 11 rows (deepseek-flash,
zen gpt-5.6, qianfan ernie, codex gpt-5.6, zen, telecomjs, modelstudio,
edenai, concentrate, codewhale, custom). Also seeds `deepseek-flash` in the
TUI model registry (the list documents that it mirrors DEFAULT_*_MODEL) and
adds it to the DeepSeek web-search capability arm.

Evidence:
- before the fallback, the new test listed deepseek-flash first among 11
  unresolved defaults; live `model resolve deepseek-flash` errored
- cargo test -p codewhale-agent --locked
  test result: ok. 70 passed; 0 failed  (was 2 passed; 1 failed on HEAD for
  explicit_deepseek_selection_retains_its_provider_owned_default, already red)
- cargo test -p codewhale-cli --locked --test model_resolve_provenance
  test result: ok. 15 passed; 0 failed
- cargo test -p codewhale-tui --lib --locked -- model_registry
  test result: ok. 10 passed; 0 failed
- cargo test -p codewhale-config --locked -- capabilities
  test result: ok. 7 passed; 0 failed; 1 passed (two targets)
This commit is contained in:
CodeWhale Bot committed 2026-09-10 00:52:18 -07:00
1 parent a591e2c690
commit 5ad9cdbbd0
3 files changed
+92 -18

No files matched your search

+85 -17
View File
@@ -1541,6 +1541,22 @@ impl ModelRegistry {
fallback_chain,
});
}
// A provider's own declared default is available from that
// provider by definition — the descriptor owns that fact (#6443:
// `deepseek-flash` is the Deepseek default and resolved nowhere).
// Registry rows canonicalize aliases and carry capability
// metadata; they must not gate the name the provider declares.
let declared_default = provider.provider().default_model();
if !declared_default.trim().is_empty()
&& name.trim().eq_ignore_ascii_case(declared_default.trim())
{
return Ok(ModelResolution {
requested: Some(name.to_string()),
resolved: Self::descriptor_default_model(provider, declared_default),
used_fallback: false,
fallback_chain,
});
}
if !self.models.iter().any(|model| model.provider == provider) {
return Err(ModelResolutionError::ProviderHasNoModels {
provider,
@@ -1554,12 +1570,6 @@ impl ModelRegistry {
}
fallback_chain.push(format!("provider_default:{}", provider.as_str()));
if !self.models.iter().any(|model| model.provider == provider) {
return Err(ModelResolutionError::ProviderHasNoModels {
provider,
requested: None,
});
}
let default_model = provider.provider().default_model();
if let Some(model) = self
.models
@@ -1574,12 +1584,45 @@ impl ModelRegistry {
fallback_chain,
});
}
// Same rule as the explicit branch: the descriptor's declared default
// resolves for its own provider even without a registry row (#6443).
// Ollama is the exception: its descriptor default is the placeholder
// `unknown`, and the real default comes from the live local catalog
// (Y-2), so a placeholder must never resolve as a model.
if !default_model.trim().is_empty() && provider != ProviderKind::Ollama {
return Ok(ModelResolution {
requested: None,
resolved: Self::descriptor_default_model(provider, default_model),
used_fallback: true,
fallback_chain,
});
}
if !self.models.iter().any(|model| model.provider == provider) {
return Err(ModelResolutionError::ProviderHasNoModels {
provider,
requested: None,
});
}
Err(ModelResolutionError::ProviderDefaultUnavailable {
provider,
default_model: default_model.to_string(),
})
}
/// The [`ModelInfo`] a provider's declared default resolves to when the
/// registry carries no explicit row for it. The descriptor owns the
/// identity; capability metadata stays conservative rather than
/// fabricating a capability the registry never recorded.
fn descriptor_default_model(provider: ProviderKind, id: &str) -> ModelInfo {
ModelInfo {
id: id.trim().to_string(),
provider,
aliases: Vec::new(),
supports_tools: true,
supports_reasoning: false,
}
}
}
fn normalize(value: &str) -> String {
@@ -1825,7 +1868,10 @@ mod tests {
let resolved = registry.resolve_ok(None, Some(ProviderKind::Deepseek));
assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
// The descriptor's declared default, not the first cloud row: the
// registry's Deepseek rows start at v4-pro, and borrowing that here is
// exactly the mismatch #6443 fixed.
assert_eq!(resolved.resolved.id, "deepseek-flash");
assert!(resolved.used_fallback);
assert_eq!(resolved.fallback_chain, ["provider_default:deepseek"]);
}
@@ -1845,7 +1891,10 @@ mod tests {
}
#[test]
fn missing_provider_default_row_fails_instead_of_borrowing_another_model() {
fn provider_default_without_a_registry_row_resolves_to_its_own_id() {
// SHA-6443: the descriptor owns its declared default. A registry that
// carries unrelated rows must still resolve the provider's own
// default — and must never borrow another provider's model.
let registry = ModelRegistry::new(vec![ModelInfo {
id: "not-the-openai-default".to_string(),
provider: ProviderKind::Openai,
@@ -1854,16 +1903,12 @@ mod tests {
supports_reasoning: true,
}]);
let error = registry
let resolved = registry
.resolve(None, Some(ProviderKind::Openai))
.expect_err("a missing provider default must fail closed");
assert_eq!(
error,
ModelResolutionError::ProviderDefaultUnavailable {
provider: ProviderKind::Openai,
default_model: "gpt-5.6".to_string(),
}
);
.expect("the provider's declared default resolves for that provider");
assert_eq!(resolved.resolved.id, "gpt-5.6");
assert_eq!(resolved.resolved.provider, ProviderKind::Openai);
assert!(resolved.used_fallback);
}
#[test]
@@ -2801,4 +2846,27 @@ mod tests {
);
assert_eq!(model_family(""), ModelFamily::Inferencer);
}
/// SHA-6443: a provider's declared default must be a model its own
/// registry can resolve. A default the registry rejects fails a test
/// here, not a founder's `model resolve`.
#[test]
fn every_provider_default_resolves_for_its_own_provider() {
let registry = ModelRegistry::default();
let mut failures = Vec::new();
for kind in ProviderKind::all() {
let default = kind.provider().default_model();
if default.trim().is_empty() {
continue;
}
if let Err(error) = registry.resolve(Some(default), Some(*kind)) {
failures.push(format!("{} ({kind:?}): {error}", default));
}
}
assert!(
failures.is_empty(),
"provider defaults must resolve for their own provider:\n{}",
failures.join("\n")
);
}
}
+4 -1
View File
@@ -93,7 +93,10 @@ pub(crate) fn documented_server_side_web_search(
),
"deepseek" => matches!(
wire_model_id.as_str(),
"deepseek-v4-flash" | "deepseek-v4-pro" | "deepseek-v4-flash-vision-exp"
"deepseek-flash"
| "deepseek-v4-flash"
| "deepseek-v4-pro"
| "deepseek-v4-flash-vision-exp"
),
"moonshot" => matches!(wire_model_id.as_str(), "kimi-k3" | "kimi-k2.6"),
_ => false,
+3
View File
@@ -129,6 +129,9 @@ const SEED_MODEL_IDS: &[(&str, ModelProvider)] = &[
// / Atlascloud / Novita / Fireworks / Siliconflow / SGLang / vLLM /
// Huggingface / Together / Volcengine / WanjieArk / Ollama defaults) ---
("deepseek-v4-pro", ModelProvider::DeepSeek),
// The official V4.1 id and the config default: it must be seeded here so
// this list keeps mirroring the DEFAULT_* constants it documents.
("deepseek-flash", ModelProvider::DeepSeek),
("deepseek-v4-flash", ModelProvider::DeepSeek),
("deepseek-v4-flash-vision-exp", ModelProvider::DeepSeek),
("deepseek-ai/deepseek-v4-pro", ModelProvider::DeepSeek),